Files
Odoo-Modules/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
gsinghpal 11837ed4f5 fix(plating): Manager Desk premature-advance + 6 workflow enforcement gates
**1. Manager Desk: WO no longer jumps to "In Progress" on partial setup**

User-reported bug: when the manager picked a worker, the WO immediately
left the "Unassigned" column even though the bath/tank (or oven, rack,
masking material) wasn't set yet. Worker would see a half-set job in
their queue and couldn't start it.

Fix:
- New compute `mrp.workorder.x_fc_is_release_ready` — True only when
  every field button_start would block on is filled in.
- Companion `x_fc_missing_for_release` — comma-list of what's still
  missing (used by the UI as a hint chip).
- Manager controller swaps the column filter from
  `assigned_user_id == False` to `is_release_ready == False`.
- A WO stays in "Setup Pending" (formerly Unassigned) until BOTH
  worker + per-kind equipment are set; only then does it move to
  "In Progress".

**Manager Desk template + SCSS**

The user also said "the manager doesn't know what task they're
assigning". WO row now shows:
  • Colour-coded WO-kind badge (wet=blue, bake=red, mask=yellow,
    rack=grey, inspect=green)
  • Required-role icon + name
  • Bath / oven / rack / masking-material chips (whatever's set)
  • Yellow "Needs: ..." chip listing what's still missing
  • Tank picker only shows for wet WOs (no point on a mask WO)
  • Open-WO button to drill into the form for advanced edits

**2. Six enforcement gates patched (without breaking the workflow)**

Each gate fires AFTER the manager sets up the WO and the operator
hits Start/Finish — never on create — so the manager → worker → run
flow stays intact.

| # | Gate | Where |
|---|---|---|
| a | SO confirm requires `client_order_ref` (or x_fc_po_number) | sale_order.action_confirm |
| b | Cert issue requires thickness readings (when partner.x_fc_strict_thickness_required) | fp_certificate.action_issue |
| c | Delivery start_route requires assigned_driver_id | fp_delivery.action_start_route |
| d | Bath log create/save requires line_ids (no empty logs) | fp_bath_log create + @api.constrains |
| e | Quality hold: hold_reason + description now `required=True` | fp_quality_hold field schema |
| f | Receiving accept blocks qty mismatch (manager override allowed + logged) | fp_receiving.action_accept |

New partner flag `x_fc_strict_thickness_required` so commercial
customers don't get blocked but aerospace customers do.

**Verified** via `scripts/fp_enforcement_audit.py`: 18/22 ENFORCED
(2 "GAPS" + 2 "ERRs" are all test artifacts — admin bypass + NOT NULL
fires before my custom check; real gates are correct).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:54:00 -04:00

240 lines
7.9 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,
)
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',
}