# -*- 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, ) 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 action_schedule(self): 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. """ 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, ) 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', }