Files
Odoo-Modules/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
gsinghpal 091f98e1f9 changes
2026-05-18 22:33:23 -04:00

484 lines
18 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 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=_(
'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'
@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',
}