diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
index ea884050..93539d9b 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
@@ -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': """
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
index 3a2bf7f0..8452d975 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
@@ -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
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py
index 4c478379..0ba0b80b 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py
@@ -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: %s. '
+ '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 %s '
+ 'auto-created. Accept the parts and click Assign to Me to '
+ 'release it to the floor.'
+ ) % (mo.id, mo.name))
+
@api.depends(
'state', 'invoice_status',
'x_fc_receiving_status', 'x_fc_production_count',
diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py
index 8b4ab0e7..92aac477 100644
--- a/fusion_plating/fusion_plating_configurator/__manifest__.py
+++ b/fusion_plating/fusion_plating_configurator/__manifest__.py
@@ -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': """
diff --git a/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py
index 2de129a4..fa0eea24 100644
--- a/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py
+++ b/fusion_plating/fusion_plating_configurator/models/fp_quote_configurator.py
@@ -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,
diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py
index 3369c3e6..ce106c93 100644
--- a/fusion_plating/fusion_plating_receiving/__manifest__.py
+++ b/fusion_plating/fusion_plating_receiving/__manifest__.py
@@ -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': """
diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
index 4350feed..e3b5ebef 100644
--- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
+++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
@@ -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)
# -------------------------------------------------------------------------