Files
Odoo-Modules/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
gsinghpal d6d6249857 changes
2026-05-21 04:47:45 -04:00

580 lines
22 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpDelivery(models.Model):
"""Scheduled delivery of finished parts back to a customer.
Lifecycle:
draft → scheduled → en_route → delivered
→ refused
→ returned
→ cancelled
A delivery references a job by soft reference (`job_ref`) because
the job module is not yet built. Once the job module ships, this
field can be deprecated in favour of a proper Many2one without
migration.
"""
_name = 'fusion.plating.delivery'
_description = 'Fusion Plating — Delivery'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
_order = 'scheduled_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
default='New',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
tracking=True,
)
delivery_address_id = fields.Many2one(
'res.partner',
string='Delivery Address',
help='Leave blank to use the customer default address.',
)
contact_name = fields.Char(
string='Contact Name',
)
contact_phone = fields.Char(
string='Contact Phone',
)
job_ref = fields.Char(
string='Job Reference',
help='Soft reference to the job this delivery belongs to. '
'Will become a Many2one once the job module ships.',
tracking=True,
)
# ---- Sub 5 — traceability fields from the source MO --------------------
x_fc_serial_id = fields.Many2one(
'fp.serial', string='Serial Number',
ondelete='set null', index=True,
help='Serial copied from the MO when bridge_mrp drafts this delivery.',
)
x_fc_job_number = fields.Char(
string='Job #', index=True,
help='Shop-floor job number from the MO. Prints on packing slip.',
)
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Carried from the SO line — prints on packing slip / BoL.',
)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
)
# ---- Sub 8 — box parity ------------------------------------------------
# Shipping crew packs returns into the SAME boxes the parts arrived in
# (client requirement). Receiving captures box_count_in; we capture
# box_count_out here. action_mark_delivered posts a non-blocking
# chatter warning if they don't match.
x_fc_box_count_out = fields.Integer(
string='Boxes Out',
help='Number of boxes the shipping crew packed for return. '
'Should match the box count captured at receiving.',
)
scheduled_date = fields.Datetime(
string='Scheduled Date',
tracking=True,
)
assigned_driver_id = fields.Many2one(
'hr.employee',
string='Assigned Driver',
tracking=True,
domain=[('x_fc_is_driver', '=', True)],
)
vehicle_id = fields.Many2one(
'fusion.plating.vehicle',
string='Vehicle',
tracking=True,
)
source_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Source Facility',
tracking=True,
)
company_id = fields.Many2one(
'res.company',
related='source_facility_id.company_id',
store=True,
readonly=True,
)
tdg_required = fields.Boolean(
string='TDG Required',
tracking=True,
)
coc_attachment_id = fields.Many2one(
'ir.attachment',
string='Certificate of Conformance',
)
packing_list_attachment_id = fields.Many2one(
'ir.attachment',
string='Packing List',
)
# ---- Phase A — outbound carrier + shipment link ----------------------
# Mirrors the fields on fp.receiving. Populated by
# fp.job._fp_create_delivery from the linked receiving when this
# delivery is auto-created on job-done; shipping crew can override
# at ship time.
x_fc_carrier_id = fields.Many2one(
'delivery.carrier', string='Outbound Carrier', tracking=True,
ondelete='set null',
help='Carrier picked at receiving time; can be overridden by '
'the shipping crew before issuing the label.',
)
x_fc_outbound_shipment_id = fields.Many2one(
'fusion.shipment', string='Outbound Shipment', tracking=True,
ondelete='set null',
copy=False,
help='The shipment record carrying weight, dimensions, label '
'PDF, and tracking. Usually the same shipment that was '
'created at receiving time.',
)
x_fc_outbound_shipment_count = fields.Integer(
compute='_compute_x_fc_outbound_shipment_count',
)
@api.depends('x_fc_outbound_shipment_id')
def _compute_x_fc_outbound_shipment_count(self):
for rec in self:
rec.x_fc_outbound_shipment_count = (
1 if rec.x_fc_outbound_shipment_id else 0
)
@api.onchange('x_fc_carrier_id')
def _onchange_x_fc_carrier_id(self):
for rec in self:
ship = rec.x_fc_outbound_shipment_id
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
ship.carrier_id = rec.x_fc_carrier_id.id
def action_create_outbound_shipment(self):
self.ensure_one()
if self.x_fc_outbound_shipment_id:
return self.action_view_outbound_shipment()
if 'fusion.shipment' not in self.env:
raise UserError(_(
'fusion_shipping module is not installed. '
'Cannot create an outbound shipment.'
))
SO = self.env['sale.order'].sudo()
so = False
if self.job_ref:
Job = self.env.get('fp.job')
if Job is not None:
job = Job.sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
so = job.sale_order_id if job else False
vals = {
'sale_order_id': so.id if so else False,
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
'status': 'draft',
}
shipment = self.env['fusion.shipment'].sudo().create(vals)
self.x_fc_outbound_shipment_id = shipment.id
self.message_post(body=Markup(_(
'Outbound shipment <b>%s</b> created (draft).'
)) % shipment.name)
return self.action_view_outbound_shipment()
def action_view_outbound_shipment(self):
self.ensure_one()
if not self.x_fc_outbound_shipment_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_outbound_shipment_id.name,
'res_model': 'fusion.shipment',
'res_id': self.x_fc_outbound_shipment_id.id,
'view_mode': 'form',
'target': 'current',
}
state = fields.Selection(
[
('draft', 'Draft'),
('scheduled', 'Scheduled'),
('en_route', 'En Route'),
('delivered', 'Delivered'),
('refused', 'Refused'),
('returned', 'Returned'),
('cancelled', 'Cancelled'),
],
string='Status',
default='draft',
required=True,
tracking=True,
)
delivered_at = fields.Datetime(
string='Delivered At',
readonly=True,
)
pod_id = fields.Many2one(
'fusion.plating.proof.of.delivery',
string='Proof of Delivery',
copy=False,
)
notes = fields.Html(
string='Notes',
)
custody_event_ids = fields.One2many(
'fusion.plating.chain.of.custody',
'delivery_id',
string='Custody Events',
)
custody_event_count = fields.Integer(
compute='_compute_custody_count',
)
# ------------------------------------------------------------------
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
"""No direct sale_order_id on this model — resolve via
job_ref → fp.job.name → job.sale_order_id."""
if not self.job_ref or 'fp.job' not in self.env:
return self.env['sale.order']
job = self.env['fp.job'].sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
return job.sale_order_id if job else self.env['sale.order']
def _fp_name_prefix(self):
return 'DLV'
def _fp_parent_counter_field(self):
return 'x_fc_pn_delivery_count'
def action_view_coc(self):
"""Open the certificate record this delivery's CoC PDF came
from. The attachment carries res_model + res_id, so we
navigate to that record (operator gets all cert info — issue
date, void wizard, reset, etc.) rather than just opening the
raw PDF. Falls back to opening the attachment directly if
someone manually attached a PDF that isn't a cert.
"""
self.ensure_one()
att = self.coc_attachment_id
if not att:
raise UserError(_('No CoC linked to this delivery.'))
if att.res_model == 'fp.certificate' and att.res_id:
return {
'type': 'ir.actions.act_window',
'name': _('Certificate of Conformance'),
'res_model': 'fp.certificate',
'res_id': att.res_id,
'view_mode': 'form',
'target': 'current',
}
# Plain attachment — open via PDF preview helper if available.
if hasattr(att, 'action_fusion_preview'):
return att.action_fusion_preview(title=att.name or 'CoC')
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%d?download=false' % att.id,
'target': 'new',
}
def action_view_packing_list(self):
"""Open the packing-list PDF via fusion_pdf_preview (or fall
back to a new tab when the preview helper isn't installed).
Packing lists don't have a backing model — they're attachments
only — so we don't navigate to a record.
"""
self.ensure_one()
att = self.packing_list_attachment_id
if not att:
raise UserError(_('No packing list attached to this delivery.'))
if hasattr(att, 'action_fusion_preview'):
return att.action_fusion_preview(
title=att.name or 'Packing List',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%d?download=false' % att.id,
'target': 'new',
}
def action_refresh_from_source(self):
"""Re-pull delivery address / contact / scheduled date / source
facility / carrier / CoC from the linked job → SO → receiving →
cert chain. Only fills BLANK fields — never overwrites operator
edits. Use when an upstream value changed after the delivery
was auto-created, or to backfill an old delivery that was
created before the auto-populate hook existed.
"""
for rec in self:
job = (rec.x_fc_job_id
if 'x_fc_job_id' in rec._fields else False)
if not job:
# Fall back via job_ref Char if M2O is empty (older data)
if rec.job_ref and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('name', '=', rec.job_ref)], limit=1,
)
if not job:
raise UserError(_(
'Delivery %s has no linked job — nothing to '
'refresh from.'
) % rec.name)
Delivery = rec.env['fusion.plating.delivery']
defaults = job._fp_resolve_delivery_defaults(Delivery)
# Drop fields the operator already filled — never clobber
# manual edits. Includes the partner/job links since those
# are non-overridable.
fill = {
k: v for k, v in defaults.items()
if v and not rec[k]
}
if not fill:
rec.message_post(body=_(
'Refresh from source: nothing to update — every '
'field already populated.'
))
continue
rec.sudo().write(fill)
rec.message_post(body=_(
'Refresh from source filled: %s'
) % ', '.join(sorted(fill.keys())))
@api.model_create_multi
def create(self, vals_list):
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence
fallback for deliveries that don't link back to an SO."""
for vals in vals_list:
if not vals.get('name'):
vals['name'] = 'New'
records = super().create(vals_list)
for rec in records:
if rec.name and rec.name != 'New':
continue
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery') or 'New'
self.env.cr.execute(
"UPDATE fusion_plating_delivery SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
@api.model
def _default_name(self):
"""Retained for any legacy caller. New code should rely on
create() — the parent-numbered mixin sets the name there."""
seq = self.env['ir.sequence'].next_by_code('fusion.plating.delivery')
return seq or '/'
def _compute_custody_count(self):
for rec in self:
rec.custody_event_count = len(rec.custody_event_ids)
def _log_custody_event(self, event_type, from_party=None, to_party=None):
self.ensure_one()
self.env['fusion.plating.chain.of.custody'].create({
'event_datetime': fields.Datetime.now(),
'event_type': event_type,
'from_party': from_party or '',
'to_party': to_party or '',
'delivery_id': self.id,
'facility_id': self.source_facility_id.id,
'recorded_by_id': self.env.user.id,
})
# ==========================================================================
# Actions
# ==========================================================================
def _fp_check_account_hold(self, action_label):
"""Block shipping when the customer is on account hold.
Enforces the third leg of the SO banner promise ("SO confirmation,
invoicing AND SHIPPING are blocked"). Resolved through
``commercial_partner_id`` so a hold on the parent company applies
even when the delivery is addressed to a child contact.
Manager bypass: ``fp_skip_account_hold=True`` in context (matches
the pattern used in fp_direct_order_wizard and the SO action_confirm
manager-override). Non-managers can't bypass.
``getattr`` is defensive — the hold field lives in
``fusion_plating_invoicing``; this module doesn't dep on it.
"""
for rec in self:
partner = rec.partner_id.commercial_partner_id
if not getattr(partner, 'x_fc_account_hold', False):
continue
if self.env.context.get('fp_skip_account_hold'):
rec.message_post(body=_(
'Account-hold check bypassed via context flag for '
'%(action)s. Customer "%(name)s" is on hold (reason: '
'%(reason)s).'
) % {
'action': action_label,
'name': partner.name,
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'N/A',
})
continue
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
if not is_manager:
raise UserError(_(
'Cannot %(action)s delivery "%(name)s" — customer "%(partner)s" '
'is on account hold.\n'
'Reason: %(reason)s\n\n'
'Contact a manager to override.'
) % {
'action': action_label,
'name': rec.name or rec.display_name,
'partner': partner.name,
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'No reason specified',
})
rec.message_post(body=_(
'Warning: Customer "%(name)s" is on account hold (reason: '
'%(reason)s). Delivery %(action)s by manager override.'
) % {
'name': partner.name,
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'N/A',
'action': action_label,
})
def action_schedule(self):
self._fp_check_account_hold(_('schedule'))
self.write({'state': 'scheduled'})
def action_start_route(self):
"""Block "en route" until at least a driver is assigned.
Vehicle is encouraged but not strictly required (some shops
let drivers grab whatever vehicle is open at the dock). Driver
is non-negotiable — without it the chain-of-custody hand-off
has no signed party and the POD can't be linked to a person.
"""
self._fp_check_account_hold(_('dispatch'))
for rec in self:
if not rec.assigned_driver_id:
raise UserError(_(
'Cannot mark delivery "%(name)s" en route — no driver '
'assigned.\n\nPick a driver on the delivery (or wait for '
'the auto-prefill to find one) before tapping Start Route.'
) % {'name': rec.name or rec.display_name})
rec.write({'state': 'en_route'})
rec._log_custody_event(
'loaded_on_vehicle',
from_party=(rec.source_facility_id.display_name or 'Facility'),
to_party=(rec.assigned_driver_id.display_name
or rec.vehicle_id.display_name
or 'Driver'),
)
def action_mark_delivered(self):
"""Block "delivered" until a Proof of Delivery exists.
The driver must capture POD (signature, photos, recipient name)
on the iPad at the customer's dock BEFORE marking delivered.
Without POD we have no signed receipt to attach to the
invoice and no defence against a delivery dispute.
"""
for rec in self:
if not rec.pod_id:
raise UserError(_(
'Cannot mark delivery "%(name)s" delivered — no Proof '
'of Delivery (POD) has been captured.\n\n'
'On the iPad: Capture POD → enter recipient name + '
'signature → save. Then mark delivered.'
) % {'name': rec.name or rec.display_name})
rec.write({
'state': 'delivered',
'delivered_at': fields.Datetime.now(),
})
rec._log_custody_event(
'delivered_to_customer',
from_party=(rec.assigned_driver_id.display_name
or rec.vehicle_id.display_name
or 'Driver'),
to_party=rec.partner_id.display_name,
)
# Sub 8 — box-parity warning. Non-blocking; just posts to
# chatter so the shipping supervisor sees it on the record.
rec._fp_check_box_parity()
def _fp_check_box_parity(self):
"""Compare this delivery's boxes-out count to the boxes-in count
captured at receiving. Post a chatter warning if they differ.
Never blocks — shipping has already happened by the time this
fires. The warning is for audit + shipping-supervisor review.
"""
self.ensure_one()
if not self.x_fc_box_count_out:
return
if 'fp.receiving' not in self.env:
return
# Sub 11 — resolve SO via job_ref → fp.job.origin → SO.name.
so_name = False
if self.job_ref and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
if job and job.origin:
so_name = job.origin
if not so_name:
return
so = self.env['sale.order'].search(
[('name', '=', so_name)], limit=1,
)
if not so:
return
recv = Receiving.search(
[('sale_order_id', '=', so.id)], limit=1,
)
if not recv or not recv.box_count_in:
return
if recv.box_count_in != self.x_fc_box_count_out:
self.message_post(body=_(
'Box parity check: shipped %(out)d box(es), received '
'%(in)d. Verify consolidation was intended.'
) % {'out': self.x_fc_box_count_out, 'in': recv.box_count_in})
def action_mark_refused(self):
self.write({'state': 'refused'})
def action_mark_returned(self):
self.write({'state': 'returned'})
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_reset_to_draft(self):
self.write({'state': 'draft'})
def action_create_pod(self):
"""Create a blank POD record for this delivery and open it."""
self.ensure_one()
pod = self.env['fusion.plating.proof.of.delivery'].create({
'delivery_id': self.id,
'delivered_at': fields.Datetime.now(),
})
self.pod_id = pod.id
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.proof.of.delivery',
'res_id': pod.id,
'view_mode': 'form',
'target': 'current',
}