This commit is contained in:
gsinghpal
2026-05-18 22:33:23 -04:00
parent 25f568f225
commit 091f98e1f9
76 changed files with 4521 additions and 220 deletions

View File

@@ -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',

View File

@@ -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([

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
20 access_fp_workflow_state_op fp.workflow.state.operator model_fp_job_workflow_state fusion_plating.group_fusion_plating_operator 1 0 0 0
21 access_fp_workflow_state_sup fp.workflow.state.supervisor model_fp_job_workflow_state fusion_plating.group_fusion_plating_supervisor 1 0 0 0
22 access_fp_workflow_state_mgr fp.workflow.state.manager model_fp_job_workflow_state fusion_plating.group_fusion_plating_manager 1 1 1 1
23 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
24 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
25 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
26 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
27 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
28 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

View File

@@ -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)

View File

@@ -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&#160;2 of the CoC.
and the Fischerscope PDF will be merged into
page&#160;2 of the CoC.
</p>
</div>
</page>

View File

@@ -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>

View File

@@ -4,3 +4,4 @@
from . import fp_job_step_move_wizard
from . import fp_job_step_input_wizard
from . import fp_cert_issue_wizard

View File

@@ -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))

View File

@@ -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 &amp; Issue"
class="btn-primary"
invisible="has_blocking_lines"/>
<button name="action_confirm" type="object"
string="Confirm &amp; 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>