Files
Odoo-Modules/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
gsinghpal 6b7b44264a changes
2026-05-10 10:25:12 -04:00

363 lines
13 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']
_order = 'scheduled_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
default=lambda self: self._default_name(),
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_id = fields.Many2one(
'fp.coating.thickness', string='Thickness',
ondelete='set null',
)
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',
)
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',
)
@api.model
def _default_name(self):
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',
}