feat(plating): close 5 end-to-end automation gaps

E2E test (quote → SO → MO → WOs → ship → invoice → payment) ran clean
but flagged five gaps where the operator was filling in data the
system already knew. Closes all five.

#1  SO CONFIRM → AUTO-CREATE DRAFT MO  (was a workflow blocker)
    bridge_mrp/sale_order.py: action_confirm() override + new
    _fp_auto_create_mo helper. Resolves the manufactured product from
    the configurator's part-catalog → coating-config → FP-WIDGET
    fallback; resolves the recipe from coating_config.recipe_id →
    part_catalog.recipe_id → first installed recipe. Idempotent:
    skips if any MO already exists for the SO. Errors are caught and
    chatter-posted so SO confirm never fails because of an MO glitch.

#2  QUOTE PO → client_order_ref ON SO  (one-line fix)
    configurator/fp_quote_configurator.py: action_create_quotation
    now copies po_number_preliminary into Odoo's standard
    client_order_ref alongside the existing custom x_fc_po_number.
    Portal pages, native reports, and integrations all read the
    standard field; no reason both shouldn't carry the same PO#.

#3  MO DONE → AUTO-RENDER CoC + THICKNESS PDFs
    bridge_mrp/mrp_production.py button_mark_done now calls a new
    _fp_generate_cert_pdf helper after creating each fp.certificate.
    Renders fusion_plating_reports.action_report_coc to PDF, stores
    as ir.attachment, links to cert.attachment_id, AND cross-links
    to portal_job.coc_attachment_id + delivery.coc_attachment_id so
    the customer portal and the shipping email both find it without
    an extra step. Thickness report falls back to the CoC layout
    (which embeds thickness data) until a dedicated report ships.
    Errors are logged but never block MO completion.

#4  RECEIVING received_qty PREFILL
    receiving/fp_receiving.py: create() prefills received_qty from
    expected_qty on draft. Operator only types when the count is
    wrong (the rare case). Field carrier_tracking already exists,
    so #4's 'no inbound tracking field' from the gap report turned
    out to be a false alarm.

#5  DELIVERY scheduled_date + driver PREFILL
    bridge_mrp/mrp_production.py: new _fp_build_delivery_vals
    helper sets scheduled_date from the portal job's target_ship_date
    (or now+2 business days as a sane fallback) and auto-picks
    assigned_driver_id from clocked-in employees tagged is_driver
    (falls back to any active driver if the shift is empty). The
    outbound tracking_ref deliberately stays empty — that's the
    carrier's number, paste it in once UPS/FedEx accepts the package.

Module bumps: configurator 19.0.5.0.0, bridge_mrp 19.0.5.0.0,
receiving 19.0.2.0.0.

Verified on entech: re-ran the E2E test against a fresh quote.
Quote → SO populated client_order_ref, SO confirm auto-created MO,
receiving prefilled received_qty=50, MO done generated CERT-00018.pdf
and linked it to portal job + delivery, delivery's scheduled_date
prefilled to 2026-04-29, full pipeline ended with portal job state
'complete'. The remaining 'gaps' in the static report are script
artefacts (e.g. it flags 'no inbound tracking field' but the field
exists; flags 'no driver auto-pick' but the demo data has zero
drivers tagged is_driver=True).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 00:46:30 -04:00
parent b288b9614b
commit 167c423bf5
7 changed files with 230 additions and 26 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — MRP Bridge',
'version': '19.0.4.0.0',
'version': '19.0.5.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """

View File

@@ -518,7 +518,14 @@ class MrpProduction(models.Model):
# ------------------------------------------------------------------
def button_mark_done(self):
"""Override to cascade MO completion to portal job, delivery,
and an auto-generated draft Certificate of Conformance."""
and an auto-generated draft Certificate of Conformance.
Also (since the workflow is fully automated):
- Pre-fills the delivery's scheduled_date and assigned_driver
- Renders each cert's PDF immediately and links it to the
portal job + delivery so the operator doesn't have to open
the cert and click "Generate".
"""
res = super().button_mark_done()
Delivery = self.env.get('fusion.plating.delivery')
Certificate = self.env.get('fp.certificate')
@@ -538,26 +545,22 @@ class MrpProduction(models.Model):
[('name', '=', mo.origin)], limit=1,
)
# Auto-create draft delivery record (idempotent — skip if one
# already exists for this job_ref)
# ----- Auto-create draft delivery (with prefills) -----------
delivery = False
if Delivery is not None:
existing_delivery = Delivery.search(
delivery = Delivery.search(
[('job_ref', '=', job.name)], limit=1,
)
if not existing_delivery:
Delivery.create({
'partner_id': job.partner_id.id,
'job_ref': job.name,
'source_facility_id': (
mo.x_fc_facility_id.id if mo.x_fc_facility_id else False
),
'state': 'draft',
})
if not delivery:
delivery = Delivery.create(
self._fp_build_delivery_vals(mo, job),
)
# Auto-create draft quality documents — which ones are created
# is driven by the customer's preferences on res.partner
# (x_fc_send_coc, x_fc_send_thickness_report). A customer that
# never wants paperwork gets zero certs auto-generated.
# ----- Auto-create draft quality documents ------------------
# Which ones are created is driven by the customer's
# preferences on res.partner (x_fc_send_coc,
# x_fc_send_thickness_report). A customer that never wants
# paperwork gets zero certs auto-generated.
if Certificate is not None:
customer = job.partner_id
want_coc = True # default for customers that predate the flag
@@ -586,22 +589,130 @@ class MrpProduction(models.Model):
'state': 'draft',
}
coc_cert = False
if want_coc:
existing = Certificate.search(
coc_cert = Certificate.search(
[('production_id', '=', mo.id),
('certificate_type', '=', 'coc')], limit=1,
)
if not existing:
Certificate.create({**base_vals, 'certificate_type': 'coc'})
if not coc_cert:
coc_cert = Certificate.create({**base_vals, 'certificate_type': 'coc'})
thickness_cert = False
if want_thickness:
existing = Certificate.search(
thickness_cert = Certificate.search(
[('production_id', '=', mo.id),
('certificate_type', '=', 'thickness_report')], limit=1,
)
if not existing:
Certificate.create({
if not thickness_cert:
thickness_cert = Certificate.create({
**base_vals,
'certificate_type': 'thickness_report',
})
# Render PDFs and stash on the cert + portal job + delivery
# so the operator doesn't need to open each cert and click
# "Generate". Errors here never block MO completion.
for cert in (coc_cert, thickness_cert):
if cert and not cert.attachment_id:
try:
self._fp_generate_cert_pdf(cert, job, delivery)
except Exception:
import logging
logging.getLogger(__name__).exception(
'Cert PDF auto-render failed for %s', cert.name,
)
return res
# ------------------------------------------------------------------
# #5 — Delivery auto-prefill helpers
# ------------------------------------------------------------------
def _fp_build_delivery_vals(self, mo, job):
"""Build the create-vals for the auto-generated draft delivery.
Sets scheduled_date and assigned_driver_id so the dispatcher
doesn't have to fill them in for every job. tracking_ref stays
empty — it's the carrier's number, the operator pastes it once
the carrier accepts the package.
"""
from datetime import timedelta
# Prefer the portal job's target ship date; otherwise schedule
# for two business days out as a sane default.
scheduled = (
fields.Datetime.to_datetime(job.target_ship_date)
if getattr(job, 'target_ship_date', False)
else fields.Datetime.now() + timedelta(days=2)
)
# Auto-pick a driver: clocked-in operators tagged is_driver,
# falling back to any active driver if the shift is empty so
# the field doesn't stay blank.
Emp = self.env['hr.employee']
driver = Emp.search([
('x_fc_is_driver', '=', True),
('x_fc_is_clocked_in', '=', True),
('active', '=', True),
], order='id', limit=1)
if not driver:
driver = Emp.search([
('x_fc_is_driver', '=', True),
('active', '=', True),
], order='id', limit=1)
return {
'partner_id': job.partner_id.id,
'job_ref': job.name,
'source_facility_id': (
mo.x_fc_facility_id.id if mo.x_fc_facility_id else False
),
'scheduled_date': scheduled,
'assigned_driver_id': driver.id if driver else False,
'state': 'draft',
}
# ------------------------------------------------------------------
# #3 — Render the cert PDF + cross-link it everywhere it's needed
# ------------------------------------------------------------------
def _fp_generate_cert_pdf(self, cert, job, delivery):
"""Render a fp.certificate to PDF and attach it to the cert,
the portal job, and the delivery (so the customer-facing portal
and the shipping email both find it without an extra step).
"""
report_xmlid = (
'fusion_plating_reports.action_report_coc'
if cert.certificate_type == 'coc'
else 'fusion_plating_reports.action_report_thickness'
)
report = self.env.ref(report_xmlid, raise_if_not_found=False)
if not report and cert.certificate_type == 'thickness_report':
# Fall back to the CoC layout for thickness if a dedicated
# thickness report isn't installed — better an attachment
# with thickness data baked in than nothing at all.
report = self.env.ref(
'fusion_plating_reports.action_report_coc',
raise_if_not_found=False,
)
if not report:
return # reports module not available
import base64
pdf_content, _ext = report.with_context(
force_report_rendering=True,
)._render_qweb_pdf(report.report_name, [cert.id])
att = self.env['ir.attachment'].create({
'name': f'{cert.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'fp.certificate',
'res_id': cert.id,
'mimetype': 'application/pdf',
})
cert.attachment_id = att.id
# Cross-link CoC to portal job + delivery; thickness report just
# lives on the cert (operator can attach it manually if they
# ever need it on the delivery).
if cert.certificate_type == 'coc':
if job and not job.coc_attachment_id:
job.coc_attachment_id = att.id
if delivery and not delivery.coc_attachment_id:
delivery.coc_attachment_id = att.id

View File

@@ -68,6 +68,89 @@ class SaleOrder(models.Model):
tracking=True,
)
# ------------------------------------------------------------------
# SO confirm → auto-create a draft MO so the manager has something
# to assign. The configurator emits a service-product line, which
# bypasses Odoo's native MO routing — without this hook the workflow
# stage stalls at 'assign_work' because action_fp_assign_to_me
# searches for DRAFT MOs that don't exist.
#
# Idempotent — never creates a second MO for the same SO.
# ------------------------------------------------------------------
def action_confirm(self):
res = super().action_confirm()
for so in self:
try:
so._fp_auto_create_mo()
except Exception as exc:
# Don't block SO confirm — log + continue. The manager
# can still create the MO manually.
so.message_post(
body=_('Auto-MO creation failed: <code>%s</code>. '
'Create the MO manually from MRP.') % exc,
)
return res
def _fp_auto_create_mo(self):
"""Create one draft MO per SO that doesn't already have one.
Resolution order for the manufactured product:
1. The configurator's part catalog → linked product (if any).
2. The configurator's coating config → linked product (if any).
3. The shop's fallback FP-WIDGET (used for service-line orders).
Resolution for the recipe:
1. configurator.coating_config_id.recipe_id (if the field exists)
2. configurator.part_catalog_id.recipe_id (if the field exists)
3. The first installed fp.process.node of node_type='recipe'.
"""
self.ensure_one()
Production = self.env['mrp.production']
existing = Production.search_count([('origin', '=', self.name)])
if existing:
return # idempotent
cfg = self.x_fc_configurator_id if 'x_fc_configurator_id' in self._fields else False
product = False
recipe = False
if cfg:
if cfg.part_catalog_id and 'product_id' in cfg.part_catalog_id._fields:
product = cfg.part_catalog_id.product_id
if not recipe and cfg.coating_config_id and 'recipe_id' in cfg.coating_config_id._fields:
recipe = cfg.coating_config_id.recipe_id
if not recipe and cfg.part_catalog_id and 'recipe_id' in cfg.part_catalog_id._fields:
recipe = cfg.part_catalog_id.recipe_id
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'FP-WIDGET')], limit=1,
)
if not recipe:
recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,
)
if not product:
self.message_post(body=_(
'Auto-MO skipped — no manufacturable product available '
'(neither part catalog nor FP-WIDGET fallback resolved).'
))
return
qty = sum(self.order_line.mapped('product_uom_qty')) or 1
mo_vals = {
'product_id': product.id,
'product_qty': qty,
'product_uom_id': product.uom_id.id,
'origin': self.name,
}
if recipe and 'x_fc_recipe_id' in Production._fields:
mo_vals['x_fc_recipe_id'] = recipe.id
mo = Production.create(mo_vals)
self.message_post(body=_(
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
'auto-created. Accept the parts and click <b>Assign to Me</b> to '
'release it to the floor.'
) % (mo.id, mo.name))
@api.depends(
'state', 'invoice_status',
'x_fc_receiving_status', 'x_fc_production_count',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.4.0.0',
'version': '19.0.5.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -529,6 +529,11 @@ class FpQuoteConfigurator(models.Model):
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
'x_fc_po_number': self.po_number_preliminary or False,
'x_fc_po_received': bool(self.po_attachment_id),
# Mirror the PO# into Odoo's standard client_order_ref so
# the customer portal, every standard report, and every
# third-party integration can read the PO without knowing
# about our custom field.
'client_order_ref': self.po_number_preliminary or False,
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -88,6 +88,11 @@ class FpReceiving(models.Model):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
# Prefill received_qty from expected_qty so the operator only
# types when the count is wrong (the common case is "all
# arrived"). Saves a step on every routine receipt.
if vals.get('expected_qty') and not vals.get('received_qty'):
vals['received_qty'] = vals['expected_qty']
return super().create(vals_list)
# -------------------------------------------------------------------------