changes
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.8.0',
|
||||
'version': '19.0.10.14.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -67,9 +67,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'views/fp_step_priority_views.xml',
|
||||
'views/jobs_in_shopfloor_menu.xml',
|
||||
'views/legacy_menu_hide.xml',
|
||||
'views/fp_job_cert_backfill.xml',
|
||||
'views/res_users_views.xml',
|
||||
'wizards/fp_job_step_move_wizard_views.xml',
|
||||
'wizards/fp_job_step_input_wizard_views.xml',
|
||||
'wizards/fp_cert_issue_wizard_views.xml',
|
||||
'report/report_fp_job_sticker.xml',
|
||||
'report/report_fp_job_traveller.xml',
|
||||
'report/report_fp_job_wo_detail.xml',
|
||||
|
||||
@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
|
||||
'merged = already in the issued CoC PDF',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
|
||||
'x_fc_local_thickness_pdf')
|
||||
def _compute_fischer_visibility(self):
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
|
||||
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
|
||||
qc = empty_qc
|
||||
pdf = empty_att
|
||||
status = 'none'
|
||||
if QC is not None and rec.x_fc_job_id:
|
||||
# Cert-local upload wins over QC-side PDF (matches the
|
||||
# merge resolution order in fp_certificate.py).
|
||||
if rec.x_fc_local_thickness_pdf:
|
||||
if rec.state == 'issued' and rec.attachment_id:
|
||||
status = 'merged'
|
||||
else:
|
||||
status = 'pending'
|
||||
elif QC is not None and rec.x_fc_job_id:
|
||||
# Same lookup the merge method uses — passed-first,
|
||||
# then any QC with a PDF.
|
||||
qc = QC.sudo().search([
|
||||
|
||||
@@ -189,6 +189,15 @@ class FpJob(models.Model):
|
||||
back to partner-level send_coc / send_thickness_report flags.
|
||||
'none' returns empty (commercial customer, no paperwork).
|
||||
Unknown requirement codes default to {'coc'} as a safety net.
|
||||
|
||||
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
|
||||
wanted AND thickness is wanted, the thickness data is delivered
|
||||
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
|
||||
so we return ONE cert ({'coc'}) instead of two. A standalone
|
||||
thickness_report cert is only produced when thickness is wanted
|
||||
WITHOUT a CoC — a rare edge case kept for completeness.
|
||||
Action_issue's thickness-data gate enforces actual readings or
|
||||
a Fischerscope PDF on the merged CoC.
|
||||
"""
|
||||
self.ensure_one()
|
||||
req = (
|
||||
@@ -196,16 +205,17 @@ class FpJob(models.Model):
|
||||
and self.part_catalog_id.certificate_requirement
|
||||
) or 'inherit'
|
||||
if req == 'inherit':
|
||||
types = set()
|
||||
if self.partner_id.x_fc_send_coc:
|
||||
types.add('coc')
|
||||
if self.partner_id.x_fc_send_thickness_report:
|
||||
types.add('thickness_report')
|
||||
return types
|
||||
want_coc = bool(self.partner_id.x_fc_send_coc)
|
||||
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
|
||||
if want_coc:
|
||||
return {'coc'} # thickness gets merged in
|
||||
if want_thickness:
|
||||
return {'thickness_report'}
|
||||
return set()
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
'coc_thickness': {'coc'}, # bundled — thickness on page 2
|
||||
}.get(req, {'coc'})
|
||||
|
||||
next_milestone_action = fields.Selection(
|
||||
@@ -308,9 +318,29 @@ class FpJob(models.Model):
|
||||
return fn()
|
||||
|
||||
def _action_open_draft_certs(self):
|
||||
"""Open the cert list filtered to draft certs for this job.
|
||||
Manager reviews each in turn and clicks Issue per-cert."""
|
||||
"""Open the Issue Certs wizard for this job's draft certs.
|
||||
|
||||
The wizard prompts for a Fischerscope upload + readings per cert
|
||||
that needs thickness data (bundled CoC or standalone thickness
|
||||
report). Pure CoC certs (no thickness needed) appear in the
|
||||
wizard too and just need a Confirm click. Cleaner than the old
|
||||
"list view → open each cert → click Issue" flow.
|
||||
|
||||
Falls back to the cert list view if the wizard model isn't
|
||||
installed (defensive — should always exist when this module is).
|
||||
"""
|
||||
self.ensure_one()
|
||||
Wizard = self.env.get('fp.cert.issue.wizard')
|
||||
if Wizard is not None:
|
||||
try:
|
||||
return Wizard.open_for_job(self)
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: cert issue wizard failed (%s) — "
|
||||
"falling back to cert list.", self.name, e,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Draft Certificates — %s') % self.name,
|
||||
@@ -1521,6 +1551,37 @@ class FpJob(models.Model):
|
||||
job.name, job.qty, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, accounted, job.qty,
|
||||
))
|
||||
# Receiving reconciliation: parts must be physically
|
||||
# received before the job can close, and the count must
|
||||
# match what came out (done + scrapped + visual rejects).
|
||||
# Without this guard a job ships with the wrong cert qty,
|
||||
# or worse, with no closed receiving for the auditor to
|
||||
# trace back to. Same bypass flag covers both checks.
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received "
|
||||
"is blank. Close the receiving record for SO %s "
|
||||
"before completing this job."
|
||||
) % (
|
||||
job.name,
|
||||
job.sale_order_id.name if job.sale_order_id else '?',
|
||||
))
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted_out = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done "
|
||||
"(%g) + qty_scrapped (%g) + visual rejects (%g) "
|
||||
"= %g. Reconcile before closing."
|
||||
) % (
|
||||
job.name, job.qty_received,
|
||||
job.qty_done or 0, job.qty_scrapped or 0,
|
||||
rejects, accounted_out,
|
||||
))
|
||||
# QC gate: customers flagged x_fc_requires_qc must have a
|
||||
# passed QC before the job closes. AS9100 / Nadcap compliance.
|
||||
if QC and not skip_qc_gate \
|
||||
@@ -1596,6 +1657,10 @@ class FpJob(models.Model):
|
||||
refund auto-link, and the legacy notification dispatch all
|
||||
look up by job_ref. Setting both ends keeps every consumer
|
||||
happy.
|
||||
|
||||
Phase A — mirrors x_fc_carrier_id and x_fc_outbound_shipment_id
|
||||
from the linked receiving so the delivery carries the shipping
|
||||
choices made at receipt time. Shipping crew can override later.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.delivery_id:
|
||||
@@ -1612,6 +1677,25 @@ class FpJob(models.Model):
|
||||
"Job %s: fusion.plating.delivery has no job link field; "
|
||||
"delivery created without job back-reference.", self.name,
|
||||
)
|
||||
# Mirror outbound carrier + shipment from the SO's first
|
||||
# receiving record. If there are multiple receivings (split
|
||||
# shipments), the shipping crew can change either field on the
|
||||
# delivery form. Defensive: skip when fields aren't present
|
||||
# (older instance) or no receiving exists.
|
||||
if (self.sale_order_id
|
||||
and 'x_fc_receiving_ids' in self.sale_order_id._fields
|
||||
and self.sale_order_id.x_fc_receiving_ids):
|
||||
recv = self.sale_order_id.x_fc_receiving_ids[:1]
|
||||
if 'x_fc_carrier_id' in Delivery._fields \
|
||||
and 'x_fc_carrier_id' in recv._fields \
|
||||
and recv.x_fc_carrier_id:
|
||||
vals['x_fc_carrier_id'] = recv.x_fc_carrier_id.id
|
||||
if 'x_fc_outbound_shipment_id' in Delivery._fields \
|
||||
and 'x_fc_outbound_shipment_id' in recv._fields \
|
||||
and recv.x_fc_outbound_shipment_id:
|
||||
vals['x_fc_outbound_shipment_id'] = (
|
||||
recv.x_fc_outbound_shipment_id.id
|
||||
)
|
||||
try:
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
@@ -1626,13 +1710,20 @@ class FpJob(models.Model):
|
||||
on a job that already has a CoC won't create another one.
|
||||
|
||||
Each cert is pre-populated with everything action_issue needs
|
||||
(partner, spec_reference, part_number, quantity_shipped, po,
|
||||
(partner, spec_reference, process_description, certified_by,
|
||||
contact_partner, part_number, quantity_shipped, NC qty, PO,
|
||||
SO link, job link) so the manager just reviews and clicks Issue.
|
||||
|
||||
Replaces the single-CoC implementation: now honours
|
||||
part.certificate_requirement (coc / coc_thickness / none /
|
||||
inherit) and partner-level send_coc / send_thickness_report
|
||||
flags. Closes spec gap C-G1.
|
||||
Resolution sources for the new prefill fields:
|
||||
- process_description ← recipe.name (the job's process root)
|
||||
- certified_by_id ← customer_spec.signer_user_id, falling
|
||||
back to company.x_fc_owner_user_id
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
none / inherit) and partner-level send_coc /
|
||||
send_thickness_report flags. Closes spec gap C-G1.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
@@ -1645,6 +1736,25 @@ class FpJob(models.Model):
|
||||
# Spec drives the cert spec_reference. The customer.spec was
|
||||
# auto-filled onto the job at confirm time (sale_order.py).
|
||||
spec = self.customer_spec_id
|
||||
# Recipe drives the process description on the cert. Was previously
|
||||
# sourced from sale_order.x_fc_coating_config_id (since retired);
|
||||
# recipe.name is the human-readable replacement.
|
||||
recipe = self.recipe_id
|
||||
# Signer resolution: per-spec override wins, company default fills.
|
||||
signer = False
|
||||
if spec and 'signer_user_id' in spec._fields:
|
||||
signer = spec.signer_user_id
|
||||
if not signer and 'x_fc_owner_user_id' in self.company_id._fields:
|
||||
signer = self.company_id.x_fc_owner_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
+ (self.qty_visual_inspection_rejects or 0)
|
||||
)
|
||||
for cert_type in sorted(required):
|
||||
# Idempotency per type.
|
||||
existing_dom = [('certificate_type', '=', cert_type)]
|
||||
@@ -1691,6 +1801,8 @@ class FpJob(models.Model):
|
||||
(self.qty_done or self.qty or 0)
|
||||
- (self.qty_scrapped or 0)
|
||||
)
|
||||
if 'nc_quantity' in Cert._fields:
|
||||
vals['nc_quantity'] = nc_qty
|
||||
if 'po_number' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_po_number' in self.sale_order_id._fields:
|
||||
vals['po_number'] = (
|
||||
@@ -1703,8 +1815,12 @@ class FpJob(models.Model):
|
||||
vals['customer_job_no'] = (
|
||||
self.sale_order_id.x_fc_customer_job_number or ''
|
||||
)
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
if 'process_description' in Cert._fields and recipe:
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
@@ -1728,6 +1844,107 @@ class FpJob(models.Model):
|
||||
) % {'t': cert_type, 'e': e})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backfill — closed jobs missing certs, plus cleanup of legacy
|
||||
# duplicate thickness_report certs created before the bundling rule.
|
||||
# ------------------------------------------------------------------
|
||||
# One-shot management action for jobs that closed BEFORE the
|
||||
# _fp_create_certificates bug fix (e.g. WO-30040). Two passes:
|
||||
# 1. CREATE any missing draft cert per the (updated) resolver
|
||||
# 2. VOID legacy duplicate thickness_report certs that have a
|
||||
# paired CoC on the same job — the bundling rule says the
|
||||
# CoC carries the thickness data on page 2
|
||||
# Both passes are idempotent — safe to re-run.
|
||||
@api.model
|
||||
def action_backfill_missing_certs(self):
|
||||
Cert = self.env.get('fp.certificate')
|
||||
if Cert is None:
|
||||
raise UserError(_(
|
||||
'fp.certificate model is not installed. Install '
|
||||
'fusion_plating_certificates before running this action.'
|
||||
))
|
||||
candidate_jobs = self.search([('state', '=', 'done')])
|
||||
scanned = 0
|
||||
backfilled_jobs = self.env['fp.job']
|
||||
created_count = 0
|
||||
voided_count = 0
|
||||
has_job_link = 'x_fc_job_id' in Cert._fields
|
||||
for job in candidate_jobs:
|
||||
required = job._resolve_required_cert_types()
|
||||
if not required:
|
||||
continue
|
||||
scanned += 1
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else
|
||||
(Cert.sudo().search([
|
||||
('sale_order_id', '=', job.sale_order_id.id),
|
||||
]) if job.sale_order_id else Cert.browse())
|
||||
)
|
||||
existing_types = set(existing_certs.mapped('certificate_type'))
|
||||
|
||||
# ---- Pass 1: create missing certs --------------------------
|
||||
missing = required - existing_types
|
||||
if missing:
|
||||
before = len(existing_certs)
|
||||
job._fp_create_certificates()
|
||||
# Re-read to get the freshly-created ones for pass 2.
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else existing_certs
|
||||
)
|
||||
delta = max(len(existing_certs) - before, 0)
|
||||
if delta:
|
||||
backfilled_jobs |= job
|
||||
created_count += delta
|
||||
|
||||
# ---- Pass 2: void duplicate thickness_report certs ---------
|
||||
# Bundling rule (CLAUDE.md): when CoC + thickness are both
|
||||
# wanted, the CoC absorbs the thickness data. A leftover
|
||||
# draft thickness_report cert on the same job is now noise
|
||||
# and should not be issued. Void it with a clear reason so
|
||||
# the audit trail tells the story.
|
||||
if 'coc' in required and 'coc' in existing_types:
|
||||
dup_thickness = existing_certs.filtered(
|
||||
lambda c: (c.certificate_type == 'thickness_report'
|
||||
and c.state == 'draft')
|
||||
)
|
||||
for cert in dup_thickness:
|
||||
cert.sudo().write({
|
||||
'state': 'voided',
|
||||
'void_reason': (
|
||||
'Auto-voided: bundling rule — thickness '
|
||||
'data is delivered as page 2 of the paired '
|
||||
'CoC, not as a separate cert.'
|
||||
),
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Auto-voided by cleanup: bundling rule routes '
|
||||
'thickness data to the CoC.'
|
||||
))
|
||||
voided_count += 1
|
||||
backfilled_jobs |= job
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cert backfill + cleanup complete'),
|
||||
'message': _(
|
||||
'Scanned %(s)d closed jobs. Created %(c)d draft '
|
||||
'cert(s); voided %(v)d duplicate thickness_report '
|
||||
'cert(s) across %(j)d job(s).'
|
||||
) % {
|
||||
's': scanned,
|
||||
'c': created_count,
|
||||
'v': voided_count,
|
||||
'j': len(backfilled_jobs),
|
||||
},
|
||||
'sticky': True,
|
||||
'type': 'success' if (created_count or voided_count) else 'warning',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
"""Phase 7 — adds the migration idempotency key on fp.job.step.
|
||||
|
||||
|
||||
@@ -823,16 +823,67 @@ class FpJobStep(models.Model):
|
||||
'state': state_label,
|
||||
})
|
||||
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review (paperwork — doesn't
|
||||
need parts on the floor). Fires from both button_start and
|
||||
button_finish so an operator can't begin OR complete physical
|
||||
work before the receiving record is closed.
|
||||
|
||||
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
|
||||
pattern as the qty / QC / bake gates. Audit trail is preserved
|
||||
via the state-transition tracking on chatter.
|
||||
|
||||
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
|
||||
that's the terminal state (inspection moved into the recipe's
|
||||
racking step; ``'inspected'`` was dropped in the 2026-05-18
|
||||
cleanup).
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
# Internal rework / no SO — gate doesn't apply.
|
||||
continue
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
# Defensive: configurator module not installed.
|
||||
continue
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(
|
||||
so.x_fc_receiving_status,
|
||||
so.x_fc_receiving_status or 'unknown',
|
||||
)
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received '
|
||||
'yet (SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > '
|
||||
'Receiving) before starting or finishing work on '
|
||||
'this step. A manager can bypass this gate for '
|
||||
'documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
|
||||
def button_start(self):
|
||||
"""Single source of truth for step start:
|
||||
1. Sub 13 predecessor gate (raise UserError if blocking)
|
||||
2. Policy B Contract Review auto-open (route to QA-005)
|
||||
3. Sub 8 Racking auto-open (route to racking inspection)
|
||||
4. super().button_start() + receiving soft check + serial
|
||||
promotion for the standard path
|
||||
2. Receiving gate (raise UserError if parts not received)
|
||||
3. Policy B Contract Review auto-open (route to QA-005)
|
||||
4. Sub 8 Racking auto-open (route to racking inspection)
|
||||
5. super().button_start() + serial promotion for the standard
|
||||
path
|
||||
|
||||
Manager bypasses available via context:
|
||||
fp_skip_predecessor_check=True skips the Sub 13 gate
|
||||
fp_skip_receiving_gate=True skips the receiving gate
|
||||
"""
|
||||
# ---- 1. Sub 13 predecessor gate ----------------------------------
|
||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||
@@ -863,7 +914,13 @@ class FpJobStep(models.Model):
|
||||
),
|
||||
))
|
||||
|
||||
# ---- 2. Policy B Contract Review auto-open -----------------------
|
||||
# ---- 2. Receiving gate -------------------------------------------
|
||||
# Hard block (replaces the prior soft chatter warning). The
|
||||
# helper exempts Contract Review steps internally, so contract
|
||||
# review can still auto-open below regardless of receiving state.
|
||||
self._fp_check_receiving_gate()
|
||||
|
||||
# ---- 3. Policy B Contract Review auto-open -----------------------
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
action = step._fp_open_contract_review()
|
||||
@@ -873,7 +930,7 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 3. Sub 8 Racking auto-open ----------------------------------
|
||||
# ---- 4. Sub 8 Racking auto-open ----------------------------------
|
||||
for step in self:
|
||||
if step._fp_is_racking_step():
|
||||
action = step._fp_open_racking_inspection()
|
||||
@@ -883,33 +940,18 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 4. Standard path: start + receiving check + serial promote --
|
||||
# ---- 5. Standard path: start + serial promote --------------------
|
||||
result = super().button_start()
|
||||
for step in self:
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue
|
||||
recv = so.x_fc_receiving_status if (
|
||||
'x_fc_receiving_status' in so._fields
|
||||
) else None
|
||||
if recv in (False, None, 'not_received'):
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(step)s" started before parts were received '
|
||||
'(SO %(so)s — receiving status: %(status)s). '
|
||||
'Confirm the parts are physically on the floor before '
|
||||
'continuing.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '',
|
||||
'status': recv or 'unknown',
|
||||
})
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
self._fp_check_contract_review_complete()
|
||||
# Receiving gate — same helper as button_start, exempts CR steps.
|
||||
self._fp_check_receiving_gate()
|
||||
# NOTE: racking inspection gate removed — racking is now a recipe
|
||||
# step, not a separate inspection workflow. _fp_check_racking_
|
||||
# inspection_complete() is kept as a helper for diagnostics but
|
||||
|
||||
@@ -175,10 +175,13 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status in ('partial', 'received'):
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state (no
|
||||
# more separate 'inspected'). Parts are on the floor;
|
||||
# inspection happens inside the recipe's racking step.
|
||||
if not so.x_fc_assigned_manager_id and not jobs:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
@@ -562,16 +565,27 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving accepted; flip SO receiving status to inspected."""
|
||||
"""Mark receiving complete; flip SO receiving status to received.
|
||||
|
||||
Sub 8 (2026-04-22) moved inspection out of receiving and into the
|
||||
recipe's racking step. Receiving's terminal state is now 'closed'
|
||||
(or legacy 'accepted'), which maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
# Push receiving to its terminal state — 'closed' is the
|
||||
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
|
||||
# only for old records still in pre-Sub-8 states.
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state in ('inspecting',):
|
||||
rec.state = 'accepted'
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -20,3 +20,9 @@ access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_
|
||||
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_sup,fp.cert.issue.wiz.supervisor,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_mgr,fp.cert.issue.wiz.manager,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_sup,fp.cert.issue.wiz.l.supervisor,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_mgr,fp.cert.issue.wiz.l.manager,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_sup,fp.cert.issue.wiz.r.supervisor,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_mgr,fp.cert.issue.wiz.r.manager,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -589,3 +589,367 @@ class TestQtyGate(TransactionCase):
|
||||
with self.assertRaises(UserError) as exc:
|
||||
wiz.action_commit()
|
||||
self.assertIn('at least 1', str(exc.exception))
|
||||
|
||||
|
||||
class TestCertCreationAndGates(TransactionCase):
|
||||
"""2026-05-18 — cert creation bug fix + gate hardening.
|
||||
|
||||
Covers the fixes for the WO-30040 incident where
|
||||
_fp_create_certificates raised NameError on `coating` and the cert
|
||||
was never created. Also covers the new qty_received gate on
|
||||
button_mark_done and the auto-fill of certified_by_id /
|
||||
contact_partner_id / nc_quantity / process_description.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Quality Manager',
|
||||
'login': 'qa_mgr_certtest',
|
||||
'email': 'qa@example.com',
|
||||
})
|
||||
cls.contact = cls.env['res.partner'].create({
|
||||
'name': 'Bob Receiver',
|
||||
'email': 'bob@cust.example',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'CertWidget',
|
||||
})
|
||||
cls.part = cls.env['fp.part.catalog'].create({
|
||||
'name': 'CertPart',
|
||||
'part_number': 'CP-001',
|
||||
'partner_id': cls.partner.id,
|
||||
'certificate_requirement': 'coc',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'part_catalog_id': self.part.id,
|
||||
'qty': 1.0,
|
||||
'qty_done': 1.0,
|
||||
'qty_received': 1.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ---------------- bug fix regression -------------------------------
|
||||
|
||||
def test_create_cert_handles_job_with_no_recipe(self):
|
||||
"""Regression for the `coating` NameError: cert must create
|
||||
even when the job has no recipe and no coating config."""
|
||||
job = self._make_job()
|
||||
self.assertFalse(job.recipe_id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
self.assertFalse(certs.process_description)
|
||||
|
||||
# ---------------- prefill -----------------------------------------
|
||||
|
||||
def test_create_cert_prefills_signer_from_company(self):
|
||||
self.env.company.x_fc_owner_user_id = self.signer.id
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.certified_by_id, self.signer)
|
||||
|
||||
def test_create_cert_prefills_contact_from_partner(self):
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
qty=4, qty_done=3, qty_scrapped=1, qty_received=4,
|
||||
qty_visual_inspection_rejects=0,
|
||||
)
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.nc_quantity, 1)
|
||||
|
||||
# ---------------- mark_done qty_received gate ----------------------
|
||||
|
||||
def test_mark_done_blocks_on_blank_qty_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('Quantity Received', str(exc.exception))
|
||||
|
||||
def test_mark_done_blocks_on_qty_received_mismatch(self):
|
||||
from odoo.exceptions import UserError
|
||||
# received 5, accounted = 3 done + 1 scrap + 0 rejects = 4
|
||||
job = self._make_job(qty=5, qty_done=3, qty_scrapped=1,
|
||||
qty_received=5, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
# base qty reconcile passes: 3+1=4 != 5 → first gate raises first
|
||||
# rebalance so it passes the first check and fails the new one:
|
||||
job.qty = 4
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('qty mismatch', str(exc.exception).lower())
|
||||
|
||||
def test_mark_done_passes_with_clean_reconcile(self):
|
||||
job = self._make_job(qty=4, qty_done=3, qty_scrapped=1,
|
||||
qty_received=4, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(fp_skip_qc_gate=True).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
def test_mark_done_bypass_skips_qty_received_check(self):
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(
|
||||
fp_skip_qty_reconcile=True,
|
||||
fp_skip_qc_gate=True,
|
||||
).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
# ---------------- backfill action ---------------------------------
|
||||
|
||||
def test_backfill_creates_missing_certs(self):
|
||||
"""A closed job with no cert gets one when the backfill runs."""
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
# Sanity: no cert exists
|
||||
self.assertFalse(self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]))
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
self.assertEqual(self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]), 1)
|
||||
|
||||
def test_backfill_idempotent(self):
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
before = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
after = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(before, after)
|
||||
|
||||
|
||||
class TestReceivingGate(TransactionCase):
|
||||
"""2026-05-18 — Hard gate on button_start / button_finish blocking
|
||||
step transitions until SO receiving status = 'received'. Contract
|
||||
Review steps are exempt; manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`. See
|
||||
docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'RecvCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
|
||||
def _make_so(self, recv_status='not_received'):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
if 'x_fc_receiving_status' in so._fields:
|
||||
so.x_fc_receiving_status = recv_status
|
||||
return so
|
||||
|
||||
def _make_job_with_step(self, recv_status='not_received',
|
||||
step_state='ready', is_cr=False):
|
||||
"""Build a job tied to an SO with the given receiving status,
|
||||
plus a single step in the given state. Returns (job, step)."""
|
||||
so = self._make_so(recv_status=recv_status)
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
step_vals = {
|
||||
'job_id': job.id,
|
||||
'name': 'Plate',
|
||||
'state': step_state,
|
||||
}
|
||||
# If a step_kind model is available, set CR vs not via kind.
|
||||
StepKind = self.env.get('fp.step.kind')
|
||||
if StepKind is not None and is_cr:
|
||||
cr_kind = StepKind.search(
|
||||
[('code', '=', 'contract_review')], limit=1,
|
||||
)
|
||||
if cr_kind:
|
||||
step_vals['step_kind_id'] = cr_kind.id
|
||||
step = self.env['fp.job.step'].create(step_vals)
|
||||
return job, step
|
||||
|
||||
# ---- button_start gate ------------------------------------------------
|
||||
|
||||
def test_start_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_start()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_start_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(recv_status='received')
|
||||
# Should not raise; step transitions to in_progress via super().
|
||||
step.button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
def test_start_skips_contract_review(self):
|
||||
# CR step exempt regardless of receiving status.
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', is_cr=True,
|
||||
)
|
||||
# button_start may return an action (CR auto-open) — must not raise.
|
||||
try:
|
||||
step.button_start()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
# Other failures (e.g. CR auto-open quirks in test env) are
|
||||
# not the gate — accept them.
|
||||
|
||||
def test_start_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
step.with_context(fp_skip_receiving_gate=True).button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
# ---- button_finish gate -----------------------------------------------
|
||||
|
||||
def test_finish_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_finish()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_finish_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='received', step_state='in_progress',
|
||||
)
|
||||
step.button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
def test_finish_skips_contract_review(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
is_cr=True,
|
||||
)
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
|
||||
def test_finish_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
step.with_context(fp_skip_receiving_gate=True).button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
|
||||
class TestCreateDeliveryShippingMirror(TransactionCase):
|
||||
"""Phase A — _fp_create_delivery mirrors shipping fields from the
|
||||
linked receiving onto the auto-created fp.delivery."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'MirrorCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
cls.carrier_ups = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_ups',
|
||||
)
|
||||
|
||||
def _make_so_with_receiving(self, carrier=None, shipment=None):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'x_fc_carrier_id': carrier.id if carrier else False,
|
||||
'x_fc_outbound_shipment_id': shipment.id if shipment else False,
|
||||
})
|
||||
return so, recv
|
||||
|
||||
def _make_job(self, so):
|
||||
return self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
|
||||
def test_create_delivery_mirrors_carrier_from_receiving(self):
|
||||
so, recv = self._make_so_with_receiving(carrier=self.carrier_ups)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertEqual(job.delivery_id.x_fc_carrier_id, self.carrier_ups)
|
||||
|
||||
def test_create_delivery_mirrors_outbound_shipment(self):
|
||||
shipment = self.env['fusion.shipment'].create({
|
||||
'sale_order_id': False,
|
||||
'carrier_id': self.carrier_ups.id,
|
||||
'status': 'draft',
|
||||
})
|
||||
so, recv = self._make_so_with_receiving(
|
||||
carrier=self.carrier_ups, shipment=shipment,
|
||||
)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertEqual(
|
||||
job.delivery_id.x_fc_outbound_shipment_id, shipment,
|
||||
)
|
||||
|
||||
def test_create_delivery_no_receiving_no_mirror(self):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_carrier_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_outbound_shipment_id)
|
||||
|
||||
@@ -64,14 +64,15 @@
|
||||
as page 2 — open the Certificate PDF tab to verify.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
invisible="not x_fc_job_id or state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
invisible="state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
style="margin-top:0;">
|
||||
<i class="fa fa-exclamation-triangle" title="Warning"
|
||||
aria-label="Warning"/>
|
||||
<strong> No Fischerscope PDF on the linked QC.</strong>
|
||||
If this customer expects an XRF report with the CoC,
|
||||
have the operator upload the Fischerscope PDF on the
|
||||
QC check before issuing.
|
||||
<strong> No Fischerscope PDF available.</strong>
|
||||
Drop the PDF into the <em>Thickness Report
|
||||
(Fischerscope)</em> tab below, or upload it on the
|
||||
linked QC check, before issuing. Thickness Report
|
||||
certs cannot issue without thickness data.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -80,8 +81,7 @@
|
||||
<!-- Fischerscope file before merging into the cert. -->
|
||||
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||
<page string="Thickness Report (Fischerscope)"
|
||||
name="thickness_pdf"
|
||||
invisible="not x_fc_job_id">
|
||||
name="thickness_pdf">
|
||||
<group>
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
readonly="1"
|
||||
@@ -94,25 +94,23 @@
|
||||
widget="many2one_binary"
|
||||
invisible="not x_fc_thickness_pdf_id"/>
|
||||
</group>
|
||||
<separator string="Upload Fischerscope PDF here"/>
|
||||
<group>
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"
|
||||
readonly="state != 'draft'"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness PDF has been
|
||||
uploaded on the linked QC yet. The CoC will
|
||||
be issued without an appended thickness
|
||||
report. To attach one:
|
||||
uploaded yet. The CoC will be issued without
|
||||
an appended thickness report. Either drop the
|
||||
PDF into the upload field above, OR upload it
|
||||
on the linked QC check and re-open this cert.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Open the linked Plating Job (smart
|
||||
button above)</li>
|
||||
<li>Click into the auto-spawned Quality
|
||||
Check</li>
|
||||
<li>Go to the <em>Thickness Report</em> tab
|
||||
and upload the PDF from the Fischerscope
|
||||
/ XDAL 600 export</li>
|
||||
<li>Pass the QC, then come back here and
|
||||
click Issue</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
@@ -120,8 +118,8 @@
|
||||
<i class="fa fa-arrow-up" title="Action"
|
||||
aria-label="Action"/>
|
||||
Click <strong>Issue</strong> in the header
|
||||
and the Fischerscope PDF above will be
|
||||
merged into page 2 of the CoC.
|
||||
and the Fischerscope PDF will be merged into
|
||||
page 2 of the CoC.
|
||||
</p>
|
||||
</div>
|
||||
</page>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
One-shot backfill for closed jobs that never produced a CoC because
|
||||
of the `coating` NameError regression (fixed 2026-05-18). Surfaced
|
||||
as a Settings > Technical menu item so the user can click once after
|
||||
deploying the fix.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_fp_job_backfill_missing_certs" model="ir.actions.server">
|
||||
<field name="name">Generate Missing Certs for Closed Jobs</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = env['fp.job'].action_backfill_missing_certs()</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import fp_job_step_move_wizard
|
||||
from . import fp_job_step_input_wizard
|
||||
from . import fp_cert_issue_wizard
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issue Certs Wizard.
|
||||
|
||||
Opened from a job's "Issue Certs" milestone button. Walks each draft
|
||||
cert on the job, lets the manager upload the Fischerscope/XDAL output
|
||||
(PDF or .docx) per cert that needs thickness data, and tries to parse
|
||||
the .docx to pre-populate the readings table. Manager can edit/add
|
||||
readings before confirming. On confirm:
|
||||
|
||||
- PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
|
||||
of the issued CoC).
|
||||
- .docx uploads are attached as ir.attachment on the cert (evidence)
|
||||
and the parsed readings are written as fp.thickness.reading rows.
|
||||
- cert.action_issue() is called for each cert.
|
||||
|
||||
The wizard is a convenience layer — it does NOT replace the per-cert
|
||||
Issue button on the cert form, which stays as the fallback path.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Fischerscope XDAL 600 reading line, e.g.
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
_FISCHER_READING_RE = re.compile(
|
||||
r'n\s*=\s*(\d+)'
|
||||
r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
|
||||
r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
|
||||
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
|
||||
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
|
||||
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
||||
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_docx(raw_bytes):
|
||||
"""Best-effort parse of a Fischerscope XDAL 600 .docx report.
|
||||
|
||||
Returns dict:
|
||||
{
|
||||
'readings': [(nip_mils, ni_pct, p_pct), ...],
|
||||
'calibration': str or '',
|
||||
'operator': str or '',
|
||||
'date_str': str or '',
|
||||
'time_str': str or '',
|
||||
'raw_text': str (the extracted document body, for chatter),
|
||||
}
|
||||
|
||||
Soft-fails to an empty dict-like result when python-docx isn't
|
||||
installed or the bytes don't parse — the wizard still works, the
|
||||
operator just has to type readings manually.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '', 'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
try:
|
||||
import docx # python-docx
|
||||
except ImportError:
|
||||
_logger.info(
|
||||
'python-docx not installed — Fischerscope auto-parse '
|
||||
'skipped. Operator will enter readings manually.'
|
||||
)
|
||||
return empty
|
||||
try:
|
||||
doc = docx.Document(io.BytesIO(raw_bytes))
|
||||
except Exception as e:
|
||||
_logger.warning('Fischerscope .docx parse failed: %s', e)
|
||||
return empty
|
||||
# Pull text from paragraphs AND tables (Fischerscope reports
|
||||
# sometimes lay the readings inside a table cell).
|
||||
parts = [p.text for p in doc.paragraphs]
|
||||
for tbl in doc.tables:
|
||||
for row in tbl.rows:
|
||||
for cell in row.cells:
|
||||
parts.append(cell.text)
|
||||
text = '\n'.join(parts)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)), # nip mils
|
||||
float(m.group(3)), # Ni %
|
||||
float(m.group(4)), # P %
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
calib = ''
|
||||
m = _FISCHER_CALIB_RE.search(text)
|
||||
if m:
|
||||
calib = m.group(1).strip()
|
||||
operator = ''
|
||||
m = _FISCHER_OPERATOR_RE.search(text)
|
||||
if m:
|
||||
operator = m.group(1).strip()
|
||||
date_str = ''
|
||||
m = _FISCHER_DATE_RE.search(text)
|
||||
if m:
|
||||
date_str = m.group(1).strip()
|
||||
time_str = ''
|
||||
m = _FISCHER_TIME_RE.search(text)
|
||||
if m:
|
||||
time_str = m.group(1).strip()
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': calib,
|
||||
'operator': operator,
|
||||
'date_str': date_str,
|
||||
'time_str': time_str,
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizard(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard'
|
||||
|
||||
job_id = fields.Many2one(
|
||||
'fp.job', string='Job', required=True, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
|
||||
)
|
||||
has_blocking_lines = fields.Boolean(
|
||||
compute='_compute_has_blocking_lines',
|
||||
help='True when at least one line is missing data the gate '
|
||||
'requires (no readings, no file, etc.). Used to disable '
|
||||
'the Confirm button.',
|
||||
)
|
||||
|
||||
@api.depends('line_ids', 'line_ids.is_ready')
|
||||
def _compute_has_blocking_lines(self):
|
||||
for w in self:
|
||||
w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
|
||||
|
||||
@api.model
|
||||
def open_for_job(self, job):
|
||||
"""Factory — create a wizard pre-populated with one line per
|
||||
draft cert on the job. Returns an action dict that opens the
|
||||
wizard form."""
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
certs = Cert.search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
if not certs:
|
||||
raise UserError(_(
|
||||
'No draft certificates on %s to issue.'
|
||||
) % job.name)
|
||||
wiz = self.create({
|
||||
'job_id': job.id,
|
||||
'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Issue Certs — %s') % job.name,
|
||||
'res_model': self._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply every line's file + readings, then issue each cert.
|
||||
|
||||
Order matters: write the file/readings BEFORE calling action_issue
|
||||
so the gate sees the populated data. If a single cert raises on
|
||||
issue, the whole wizard rolls back (transactional).
|
||||
"""
|
||||
self.ensure_one()
|
||||
issued = []
|
||||
for ln in self.line_ids:
|
||||
ln._apply_to_cert()
|
||||
cert = ln.cert_id
|
||||
if cert.state == 'draft':
|
||||
cert.action_issue()
|
||||
issued.append(cert.name)
|
||||
if not issued:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Certs Issued'),
|
||||
'message': _('%d cert(s) issued: %s') % (
|
||||
len(issued), ', '.join(issued),
|
||||
),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizardLine(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.line'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
cert_type = fields.Selection(
|
||||
related='cert_id.certificate_type', readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
needs_thickness = fields.Boolean(
|
||||
compute='_compute_needs_thickness', store=False,
|
||||
)
|
||||
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
|
||||
fischer_filename = fields.Char(string='Filename')
|
||||
parsed_summary = fields.Text(
|
||||
string='Parsed Summary', readonly=True,
|
||||
help='Output of the .docx parser. Populated when you attach a '
|
||||
'Fischerscope .docx; the readings table below is auto-'
|
||||
'filled from the same parse. Empty for PDF uploads.',
|
||||
)
|
||||
reading_line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
|
||||
)
|
||||
is_ready = fields.Boolean(
|
||||
compute='_compute_is_ready',
|
||||
help='True when this cert has enough data to issue: thickness '
|
||||
'data present if needed.',
|
||||
)
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
def _compute_needs_thickness(self):
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
'cert_id.thickness_reading_ids',
|
||||
'cert_id.x_fc_local_thickness_pdf')
|
||||
def _compute_is_ready(self):
|
||||
for ln in self:
|
||||
if not ln.needs_thickness:
|
||||
ln.is_ready = True
|
||||
continue
|
||||
ln.is_ready = bool(
|
||||
ln.fischer_file
|
||||
or ln.reading_line_ids
|
||||
or ln.cert_id.thickness_reading_ids
|
||||
or ln.cert_id.x_fc_local_thickness_pdf
|
||||
)
|
||||
|
||||
@api.onchange('fischer_file', 'fischer_filename')
|
||||
def _onchange_fischer_file(self):
|
||||
"""Try to parse .docx on upload; prefill the readings + summary."""
|
||||
if not self.fischer_file:
|
||||
return
|
||||
name = (self.fischer_filename or '').lower()
|
||||
if not name.endswith('.docx'):
|
||||
self.parsed_summary = _(
|
||||
'Non-.docx upload (%s) — file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
except Exception:
|
||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||
return
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
readings = parsed.get('readings') or []
|
||||
if readings:
|
||||
self.reading_line_ids = [(5, 0, 0)] + [
|
||||
(0, 0, {
|
||||
'sequence': i + 1,
|
||||
'nip_mils': nip,
|
||||
'ni_percent': ni,
|
||||
'p_percent': p,
|
||||
})
|
||||
for i, (nip, ni, p) in enumerate(readings)
|
||||
]
|
||||
self.parsed_summary = _(
|
||||
'Parsed %(n)d reading(s) · Calibration: %(c)s · '
|
||||
'Operator: %(o)s · Date: %(d)s %(t)s'
|
||||
) % {
|
||||
'n': len(readings),
|
||||
'c': parsed.get('calibration') or '—',
|
||||
'o': parsed.get('operator') or '—',
|
||||
'd': parsed.get('date_str') or '—',
|
||||
't': parsed.get('time_str') or '',
|
||||
}
|
||||
|
||||
def _apply_to_cert(self):
|
||||
"""Write this line's data into the cert."""
|
||||
self.ensure_one()
|
||||
cert = self.cert_id.sudo()
|
||||
if not self.fischer_file:
|
||||
# Just push manual readings, if any.
|
||||
self._push_readings_to_cert()
|
||||
return
|
||||
name = (self.fischer_filename or 'fischerscope').lower()
|
||||
if name.endswith('.pdf'):
|
||||
# Drop the PDF into the cert-local field — merges into page 2.
|
||||
cert.write({
|
||||
'x_fc_local_thickness_pdf': self.fischer_file,
|
||||
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
||||
})
|
||||
else:
|
||||
# .doc / .docx / anything else — attach as evidence.
|
||||
self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_filename or 'fischerscope-report',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||
) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert()
|
||||
|
||||
def _push_readings_to_cert(self):
|
||||
"""Create fp.thickness.reading rows on the cert from wizard rows.
|
||||
Skips when no rows. Does not deduplicate against existing
|
||||
readings — the manager has just told us this is the new data."""
|
||||
self.ensure_one()
|
||||
Reading = self.env.get('fp.thickness.reading')
|
||||
if Reading is None or not self.reading_line_ids:
|
||||
return
|
||||
for r in self.reading_line_ids:
|
||||
vals = {
|
||||
'certificate_id': self.cert_id.id,
|
||||
'nip_mils': r.nip_mils,
|
||||
'ni_percent': r.ni_percent,
|
||||
'p_percent': r.p_percent,
|
||||
}
|
||||
if 'reading_number' in Reading._fields:
|
||||
vals['reading_number'] = r.sequence
|
||||
Reading.sudo().create(vals)
|
||||
|
||||
|
||||
class FpCertIssueWizardReading(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.reading'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Reading Row'
|
||||
_order = 'sequence, id'
|
||||
|
||||
line_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=1)
|
||||
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
|
||||
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
|
||||
p_percent = fields.Float(string='P %', digits=(6, 3))
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_issue_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.issue.wizard.form</field>
|
||||
<field name="model">fp.cert.issue.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Issue Certs">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Issue Certs —
|
||||
<field name="job_id" readonly="1" nolabel="1"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="not has_blocking_lines">
|
||||
<i class="fa fa-info-circle"/>
|
||||
At least one cert still needs thickness data
|
||||
(Fischerscope file or readings). Fill it in
|
||||
below before confirming.
|
||||
</div>
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="false" delete="false">
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness" readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="is_ready" widget="boolean_toggle"
|
||||
readonly="1"
|
||||
decoration-success="is_ready"
|
||||
decoration-danger="not is_ready"/>
|
||||
</list>
|
||||
<form>
|
||||
<header>
|
||||
<field name="is_ready" widget="statusbar"
|
||||
statusbar_visible="True,False"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness"
|
||||
readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Fischerscope File"
|
||||
invisible="not needs_thickness">
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"/>
|
||||
<field name="fischer_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<div class="text-muted"
|
||||
invisible="not needs_thickness or not parsed_summary">
|
||||
<field name="parsed_summary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<separator string="Thickness Readings"
|
||||
invisible="not needs_thickness"/>
|
||||
<field name="reading_line_ids"
|
||||
invisible="not needs_thickness">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-primary"
|
||||
invisible="has_blocking_lines"/>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-secondary"
|
||||
invisible="not has_blocking_lines"
|
||||
disabled="1"
|
||||
help="One or more certs still need thickness data."/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user