Files
Odoo-Modules/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
gsinghpal db8b79d22e feat(plating): close 6 compliance gaps from required-fields audit
Following the workforce-E2E + required-fields audit, ship the first 6
high-priority gates so critical workflow + compliance fields can no
longer be left empty by accident.

**1. Invoice payment terms (account.move)**
- create() now auto-inherits `invoice_payment_term_id` from
  partner.property_payment_term_id when missing
- action_post() raises UserError if still missing — accountant must
  pick one before posting (prevents silent "immediate" due-date)

**2. MO facility (mrp.production)**
- action_confirm() auto-derives `x_fc_facility_id` if unset, in order:
  SO override → res.company.x_fc_default_facility_id → first active
  facility — then HARD GATES: raises UserError if still empty.
  Without facility every downstream record (WO, batch, bath log,
  cert) is missing the "where" half of the audit trail.

**3. WO facility (mrp.workorder)**
- Switched `x_fc_facility_id` from related (workcenter only) to a
  proper compute that falls back to production_id.x_fc_facility_id.
  Stub workcenters auto-created from process node names usually have
  no facility — the MO always does (from #2 above).

**4. Thickness reading calibration_std (fp.thickness.reading)**
- `calibration_std_ref` is now `required=True` with sensible default
  ("NiP/Al STD SET SN 100174568"). Nadcap mandates which calibration
  standard the gauge was checked against — without it the cert
  data has no chain back to a metrology record.

**5. Delivery POD gate (fusion.plating.delivery)**
- action_mark_delivered() raises UserError if no `pod_id`. Driver
  must capture POD on the iPad (recipient signature + photos +
  notes) BEFORE marking delivered. Without POD there's no signed
  receipt to back the invoice or defend a delivery dispute.

**6. Certificate spec_reference gate (fp.certificate)**
- action_issue() raises UserError if no `spec_reference`. The cert
  ATTESTS to a spec — leaving it blank produces a piece of paper
  that AS9100 / Nadcap auditors will (rightfully) reject.

**Simulator updated**: scripts/fp_e2e_workforce.py
- Sets net-30 on the test customer + ensures a default facility
- New PHASE 4c: 5 negative tests (one per new gate), each wrapped
  in a SAVEPOINT so SQL constraint violations don't abort the txn
- Driver now creates POD on iPad BEFORE marking delivered

**Final E2E**: 48 PASS / 2 WARN / 0 FAIL out of 50 checks.
The 2 remaining WARNs (bake-window auto-create, first-piece gate)
are expected behaviour — both are coating-driven and the test
coating intentionally doesn't trigger them.

All 7 negative tests now pass:
  ✓ Test 1: WO start without operator → blocked
  ✓ Test 2: WO start on wet WO without bath/tank → blocked
  ✓ Test 3: MO confirm without facility → blocked
  ✓ Test 4: Cert issue without spec_reference → blocked
  ✓ Test 5: Delivery delivered without POD → blocked
  ✓ Test 6: Invoice post without payment terms → blocked
  ✓ Test 7: Thickness reading without cal std → blocked (DB NOT NULL)

Audit script (scripts/fp_required_fields_audit.py) committed too —
it's the diagnostic that surfaced these gaps and can be re-run to
catch new ones.

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

227 lines
7.2 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):
for rec in self:
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',
}