Split 49 modules/suites into independent git repos; untrack from monorepo
Each top-level module/suite folder is now its own private repo on GitHub (gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial commit. The monorepo no longer tracks them (added to .gitignore + git rm --cached); working-tree files are retained on disk and managed in their own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/, tools/, AGENTS.md, WIP/obsolete dirs) and full history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
# fusion_plating_jobs
|
||||
|
||||
Native plating job bridge — wires `fp.job` and `fp.job.step` (defined in
|
||||
`fusion_plating` core, Phase 1 of the migration spec dated 2026-04-25)
|
||||
into the rest of the Fusion Plating module family: configurator, portal,
|
||||
logistics, quality, certificates, batches, KPI, notifications, reports.
|
||||
|
||||
Coexists with `fusion_plating_bridge_mrp` during the migration period.
|
||||
The `x_fc_use_native_jobs` settings flag (default: `False`) toggles the
|
||||
behaviour. When `False`, SO confirm continues to create `mrp.production`
|
||||
records through `bridge_mrp`. When `True`, SO confirm creates `fp.job`
|
||||
records here.
|
||||
|
||||
See `docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md`
|
||||
for full design rationale and §6 of the implementation plan for phase
|
||||
breakdown.
|
||||
|
||||
## Phase 6 — deferred items
|
||||
|
||||
Phase 6 originally scoped the full operator UI rewrite. With Tailscale
|
||||
SSH to entech currently unavailable we cannot live-test OWL/JS in the
|
||||
browser, so Phase 6 ships a lean version: the data-layer endpoints land
|
||||
now, the rendering UI lands later.
|
||||
|
||||
Deferred to post-cutover hardening:
|
||||
|
||||
- **Plant Overview kanban** over `fp.job.step` — replaces
|
||||
`fusion_plating_shopfloor`'s `mrp.workorder` kanban.
|
||||
- **Tablet Station UI** rewrite over `fp.job` / `fp.job.step`.
|
||||
- **Manager Dashboard** rewrite.
|
||||
- **Process Tree OWL component** — currently a stub:
|
||||
`/fp/jobs/process_tree` returns the serialized recipe tree as JSON,
|
||||
but the OWL component to render it is not built.
|
||||
|
||||
Rationale: these are large OWL/JS components that need live in-browser
|
||||
verification on entech. Under the migration's parallel-coexistence
|
||||
strategy, operators continue using the existing shopfloor UI (bound to
|
||||
`mrp.workorder`) until cutover. After cutover, the operator UI rewrite
|
||||
becomes its own focused project — the data layer (`fp.job`,
|
||||
`fp.job.step`, time logs, timestamps) is fully in place from
|
||||
Phase 1–5.
|
||||
|
||||
## Phase 6 — what shipped
|
||||
|
||||
- `/fp/job/<id>` — scan-redirect controller. The fp.job sticker QR
|
||||
encodes this URL. Routes managers to the `fp.job` form; routes
|
||||
operators to the same form for now (will swap to the process tree
|
||||
client action once the OWL component lands).
|
||||
- `/fp/jobs/process_tree` — JSON-RPC endpoint that returns the recipe
|
||||
tree for a job, with each node tagged by its matching `fp.job.step`
|
||||
state, ready for an OWL component to consume.
|
||||
@@ -1,5 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import report
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
@@ -1,109 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating - Native Jobs',
|
||||
'version': '19.0.12.6.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model - replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://www.nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'support': 'support@nexasystems.ca',
|
||||
'price': 0.00,
|
||||
'currency': 'CAD',
|
||||
'description': """
|
||||
Native Plating Job Bridge
|
||||
=========================
|
||||
|
||||
Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
|
||||
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
|
||||
module family - configurator, portal, logistics, quality, certificates.
|
||||
|
||||
Coexists with fusion_plating_bridge_mrp during the migration period.
|
||||
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
|
||||
False). When False, SO confirm continues to create mrp.production records
|
||||
through bridge_mrp. When True, SO confirm creates fp.job records here.
|
||||
|
||||
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
|
||||
OWL/controller stack (job_process_tree, job_plant_overview,
|
||||
job_manager_dashboard, job_tablet) was removed. The canonical
|
||||
operator-facing UIs now live in fusion_plating_shopfloor and bind
|
||||
directly to fp.job / fp.job.step. This module retains lifecycle hooks,
|
||||
SO → fp.job creation, reports, and the QR-scan redirect.
|
||||
|
||||
See docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md for
|
||||
full design rationale and §6.2 of the implementation plan for task list.
|
||||
""",
|
||||
'depends': [
|
||||
'fusion_plating', # fp.job, fp.job.step, fp.work.centre
|
||||
'fusion_plating_batch', # fusion.plating.batch (Phase 3)
|
||||
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
|
||||
'fusion_plating_configurator', # fp.part.catalog
|
||||
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
|
||||
'fusion_plating_logistics', # fusion.plating.delivery
|
||||
'fusion_plating_notifications', # fp.notification.template (Phase 4)
|
||||
'fusion_plating_portal', # fusion.plating.portal.job
|
||||
'fusion_plating_quality', # fusion.plating.customer.spec, fusion.plating.quality.hold
|
||||
'fusion_plating_receiving', # fp.racking.inspection (Phase 3)
|
||||
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
|
||||
'fusion_plating_shopfloor', # canonical operator UI consoles
|
||||
],
|
||||
'data': [
|
||||
'security/legacy_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_cron_data.xml',
|
||||
# Spec 2026-05-25 - mail.activity.type for QM Issue-CoC nudge.
|
||||
'data/fp_activity_types_data.xml',
|
||||
# Sub 14 - workflow state catalog (must load before fp_job_form_inherit
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
'views/fp_workflow_state_views.xml',
|
||||
'views/fp_job_step_quick_look_views.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
'views/fp_job_quality_buttons.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_views.xml',
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/fp_job_consumption_views.xml',
|
||||
'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',
|
||||
'report/report_fp_job_margin.xml',
|
||||
],
|
||||
'assets': {
|
||||
# Sub 12d - Step Details quick-look modal styles
|
||||
# Sub 12e v4 - Record Inputs OWL Dialog (replaces v2/v3)
|
||||
# Both registered in both bundles so light + dark mode each
|
||||
# compile correctly ($o-webclient-color-scheme branches at
|
||||
# compile time per CLAUDE.md note).
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
|
||||
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
|
||||
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
|
||||
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
|
||||
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
|
||||
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
|
||||
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'OPL-1',
|
||||
}
|
||||
Binary file not shown.
@@ -1,6 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Consolidated 2026-04-24: the parallel OWL/controller stack was
|
||||
# removed. job_scan is the only controller retained - it powers the
|
||||
# QR-sticker scan redirect for fp.job records.
|
||||
from . import job_scan
|
||||
from . import record_inputs
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,38 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# /fp/job/<id> - scan-redirect endpoint for native fp.job stickers.
|
||||
#
|
||||
# The fp.job sticker (Phase 5) embeds a QR encoding this URL. When a
|
||||
# warehouse user scans it, this controller redirects them to either
|
||||
# the fp.job form (for managers) or the upcoming process-tree client
|
||||
# action (for operators - Phase 6 expansion).
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpJobScanController(http.Controller):
|
||||
|
||||
@http.route('/fp/job/<int:job_id>', type='http', auth='user', website=False)
|
||||
def fp_job_scan(self, job_id, **kwargs):
|
||||
Job = request.env['fp.job'].sudo()
|
||||
job = Job.browse(job_id).exists()
|
||||
if not job:
|
||||
return request.redirect('/odoo/plating-jobs')
|
||||
|
||||
# If user is a plating manager → land on the form.
|
||||
# Otherwise (operator) → land on process tree client action
|
||||
# (will be wired once process tree is added).
|
||||
user = request.env.user
|
||||
is_manager = user.has_group('fusion_plating.group_fp_manager')
|
||||
if is_manager:
|
||||
return request.redirect(
|
||||
'/odoo/action-fusion_plating.action_fp_job/%d' % job.id
|
||||
)
|
||||
# Operator path: same form for now (process tree action will replace
|
||||
# this once it's registered).
|
||||
return request.redirect(
|
||||
'/odoo/action-fusion_plating.action_fp_job/%d' % job.id
|
||||
)
|
||||
@@ -1,216 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Record Inputs Dialog (OWL) - JSONRPC backend.
|
||||
|
||||
Replaces the v3 form-based wizard with a custom OWL dialog. The dialog
|
||||
loads step + prompt metadata via /fp/record_inputs/load, then commits
|
||||
operator-entered values via /fp/record_inputs/commit.
|
||||
|
||||
Both endpoints reuse the existing fp.job.step.input.wizard TransientModel
|
||||
so the commit semantics (synthetic move row, value persistence, advance-
|
||||
after-save) match exactly what the form-based wizard did.
|
||||
"""
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpRecordInputsController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Load - return the prompt definitions + an empty values payload
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/record_inputs/load', type='jsonrpc', auth='user')
|
||||
def load(self, step_id):
|
||||
Step = request.env['fp.job.step']
|
||||
step = Step.browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.check_access('read')
|
||||
|
||||
# Mirror the wizard's default_get logic - build prompts from
|
||||
# the recipe node's input_ids filtered to step_input + collect.
|
||||
prompts = []
|
||||
node = step.recipe_node_id
|
||||
if node and (
|
||||
not hasattr(node, 'collect_measurements')
|
||||
or node.collect_measurements
|
||||
):
|
||||
inputs = node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||
if 'collect' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.collect)
|
||||
for inp in inputs.sorted('sequence'):
|
||||
prompts.append({
|
||||
'node_input_id': inp.id,
|
||||
'name': inp.name or '',
|
||||
'input_type': inp.input_type or 'text',
|
||||
'required': bool(inp.required),
|
||||
'target_min': inp.target_min or 0.0,
|
||||
'target_max': inp.target_max or 0.0,
|
||||
'target_unit': inp.target_unit or '',
|
||||
'hint': getattr(inp, 'hint', '') or '',
|
||||
'selection_options': inp.selection_options or '',
|
||||
'is_authored': True,
|
||||
})
|
||||
|
||||
# Operator initials - used by the JS dialog to pre-fill
|
||||
# signature / "Reviewer Initials" prompts. The user can edit
|
||||
# the value in the dialog and the new value is persisted back
|
||||
# via /fp/record_inputs/commit so future jobs and other steps
|
||||
# automatically pick it up.
|
||||
user = request.env.user
|
||||
try:
|
||||
user_initials = user.fp_get_initials()
|
||||
except AttributeError:
|
||||
user_initials = ''
|
||||
|
||||
# Instruction images - the recipe author's reference photos /
|
||||
# screenshots that show the operator HOW to do this step
|
||||
# (masking patterns, fixture orientation, annotated diagrams).
|
||||
# Returned as URL pointers so the dialog renders thumbnails
|
||||
# without bloating the load payload with base64.
|
||||
instruction_images = []
|
||||
if node and 'instruction_attachment_ids' in node._fields:
|
||||
for att in node.instruction_attachment_ids:
|
||||
instruction_images.append({
|
||||
'id': att.id,
|
||||
'name': att.name or '',
|
||||
'mimetype': att.mimetype or '',
|
||||
'url': '/web/image/%s' % att.id,
|
||||
})
|
||||
# Operator instructions text - shown above the prompts so the
|
||||
# author's written guidance is visible at runtime.
|
||||
instructions_html = ''
|
||||
if node and node.description:
|
||||
instructions_html = node.description
|
||||
|
||||
# Recipe root id - surfaced so the dialog's "Edit Recipe" shortcut
|
||||
# opens the Simple Editor on the EXACT recipe variant this job is
|
||||
# reading from. Avoids the trap where the operator edits a sibling
|
||||
# variant (e.g. the template, while the job runs the part-specific
|
||||
# clone) and wonders why their min/max never appears.
|
||||
recipe_root_id = False
|
||||
if node:
|
||||
root = node
|
||||
while root.parent_id:
|
||||
root = root.parent_id
|
||||
recipe_root_id = root.id
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'step': {
|
||||
'id': step.id,
|
||||
'name': step.name,
|
||||
'recipe_node_id': node.id if node else False,
|
||||
},
|
||||
'job': {
|
||||
'id': step.job_id.id,
|
||||
'name': step.job_id.name,
|
||||
},
|
||||
'recipe_root_id': recipe_root_id,
|
||||
'prompts': prompts,
|
||||
'user_initials': user_initials or '',
|
||||
'instructions_html': instructions_html or '',
|
||||
'instruction_images': instruction_images,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Commit - write values via the existing wizard (reuse semantics)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/record_inputs/commit', type='jsonrpc', auth='user')
|
||||
def commit(self, step_id, values, advance_after=False, user_initials=None):
|
||||
"""Commit operator-entered values for this step.
|
||||
|
||||
Args:
|
||||
step_id: fp.job.step id
|
||||
values: list of dicts with shape:
|
||||
{
|
||||
'node_input_id': int (or False for ad-hoc),
|
||||
'name': str,
|
||||
'input_type': str,
|
||||
'target_unit': str,
|
||||
'value_text': str | False,
|
||||
'value_number': float | 0.0,
|
||||
'value_boolean': bool,
|
||||
'value_date': str (ISO) | False,
|
||||
'photo_value': str (base64) | False,
|
||||
'photo_filename': str | False,
|
||||
'point_1' .. 'point_5': float,
|
||||
'panel_ph', 'panel_concentration',
|
||||
'panel_temperature', 'panel_bath_id',
|
||||
}
|
||||
advance_after: when True, re-enter action_finish_and_advance
|
||||
with fp_after_inputs=True so the step finishes + auto-
|
||||
starts the next.
|
||||
|
||||
Returns: {ok: bool, error: str?, next_action: dict?}
|
||||
"""
|
||||
Step = request.env['fp.job.step']
|
||||
step = Step.browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.check_access('write')
|
||||
|
||||
# Build the wizard exactly as the form-based path would, then
|
||||
# call action_commit so the audit-trail / chatter / synthetic
|
||||
# move semantics match. Pass values via line_ids so the wizard's
|
||||
# validation kicks in identically.
|
||||
Wizard = request.env['fp.job.step.input.wizard']
|
||||
line_vals = []
|
||||
for v in (values or []):
|
||||
line_vals.append((0, 0, {
|
||||
'node_input_id': v.get('node_input_id') or False,
|
||||
'name': v.get('name') or '',
|
||||
'input_type': v.get('input_type') or 'text',
|
||||
'target_unit': v.get('target_unit') or False,
|
||||
'target_min': v.get('target_min') or 0.0,
|
||||
'target_max': v.get('target_max') or 0.0,
|
||||
'value_text': v.get('value_text') or False,
|
||||
'value_number': v.get('value_number') or 0.0,
|
||||
'value_boolean': bool(v.get('value_boolean')),
|
||||
'value_date': v.get('value_date') or False,
|
||||
'photo_value': v.get('photo_value') or False,
|
||||
'photo_filename': v.get('photo_filename') or False,
|
||||
'point_1': v.get('point_1') or 0.0,
|
||||
'point_2': v.get('point_2') or 0.0,
|
||||
'point_3': v.get('point_3') or 0.0,
|
||||
'point_4': v.get('point_4') or 0.0,
|
||||
'point_5': v.get('point_5') or 0.0,
|
||||
'panel_ph': v.get('panel_ph') or 0.0,
|
||||
'panel_concentration': v.get('panel_concentration') or 0.0,
|
||||
'panel_temperature': v.get('panel_temperature') or 0.0,
|
||||
'panel_bath_id': v.get('panel_bath_id') or '',
|
||||
}))
|
||||
|
||||
wizard = Wizard.create({
|
||||
'step_id': step.id,
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
|
||||
try:
|
||||
ctx = dict(request.env.context)
|
||||
if advance_after:
|
||||
ctx['fp_advance_after_save'] = True
|
||||
result = wizard.with_context(**ctx).action_commit()
|
||||
# Persist a changed initials value on the user record so
|
||||
# the next dialog (any step, any job) auto-fills the new
|
||||
# value. Only writes when the operator explicitly typed a
|
||||
# different value than what they had stored.
|
||||
if user_initials is not None:
|
||||
cleaned = (user_initials or '').strip()
|
||||
stored = (request.env.user.x_fc_initials or '').strip()
|
||||
if cleaned and cleaned != stored:
|
||||
request.env.user.sudo().write({
|
||||
'x_fc_initials': cleaned,
|
||||
})
|
||||
return {
|
||||
'ok': True,
|
||||
'next_action': result if isinstance(result, dict) else False,
|
||||
}
|
||||
except Exception as exc:
|
||||
request.env.cr.rollback()
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
mail.activity.type for the belt-and-suspenders in-app activity
|
||||
assigned to a QM when a job transitions to awaiting_cert. Auto-
|
||||
resolves when the cert is issued and the job advances to
|
||||
awaiting_ship.
|
||||
|
||||
noupdate="1" so admin edits in the UI survive -u.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="activity_type_issue_coc" model="mail.activity.type">
|
||||
<field name="name">Issue CoC</field>
|
||||
<field name="summary">Issue Certificate of Conformance</field>
|
||||
<field name="icon">fa-certificate</field>
|
||||
<field name="delay_count">1</field>
|
||||
<field name="delay_unit">days</field>
|
||||
<field name="delay_from">current_date</field>
|
||||
<field name="res_model">fp.job</field>
|
||||
<field name="default_note">Job has finished the shop floor. Review the inspection prompts captured on the final step, then issue the CoC from the Quality Dashboard.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Daily cron - nudge supervisor for steps stuck in `paused` state
|
||||
longer than 24 hours. Schedules a mail.activity on the parent job
|
||||
so the manager sees a TODO. Idempotent - re-running the same day
|
||||
won't double-schedule.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
<record id="ir_cron_nudge_stale_paused_steps" model="ir.cron">
|
||||
<field name="name">Fusion Plating: Nudge stale paused steps</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_nudge_stale_paused(threshold_hours=24)</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Twin cron for in_progress steps. Lower threshold (8h) because
|
||||
an in_progress step has an open timelog row accumulating
|
||||
phantom hours every minute it sits idle. -->
|
||||
<record id="ir_cron_nudge_stale_in_progress_steps" model="ir.cron">
|
||||
<field name="name">Fusion Plating: Nudge stale in-progress steps</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_nudge_stale_in_progress(threshold_hours=8)</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 2 tablet redesign - actual auto-pause (not just nudge).
|
||||
Flips in_progress steps idle > N hours to paused with chatter
|
||||
audit. Threshold configurable via ir.config_parameter
|
||||
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe
|
||||
nodes can opt out via long_running=True (e.g. 24h bakes). -->
|
||||
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
|
||||
<field name="name">Fusion Plating: Auto-pause stale in-progress steps</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_autopause_stale_steps()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,79 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Sub 14 - Default 7-state workflow milestone catalog.
|
||||
|
||||
Shops can edit, deactivate, or add their own states via
|
||||
Settings → Fusion Plating → Workflow States. These records use
|
||||
noupdate="1" so per-shop edits aren't blown away on module upgrade.
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="workflow_state_draft" model="fp.job.workflow.state">
|
||||
<field name="name">Draft</field>
|
||||
<field name="code">draft</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="color">grey</field>
|
||||
<field name="is_initial" eval="True"/>
|
||||
<field name="description">Job created from SO, not yet kicked off. Awaiting setup or start-of-day batch.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_confirmed" model="fp.job.workflow.state">
|
||||
<field name="name">Confirmed</field>
|
||||
<field name="code">confirmed</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="color">blue</field>
|
||||
<field name="description">SO confirmed, recipe locked, parts on order. Financially committed.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_received" model="fp.job.workflow.state">
|
||||
<field name="name">Received</field>
|
||||
<field name="code">received</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="color">cyan</field>
|
||||
<field name="trigger_default_kinds">receiving</field>
|
||||
<field name="description">Parts physically on the floor - production CAN start. Triggered by completion of any step with default_kind='receiving'.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_in_progress" model="fp.job.workflow.state">
|
||||
<field name="name">In Progress</field>
|
||||
<field name="code">in_progress</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="color">yellow</field>
|
||||
<field name="trigger_first_step_started" eval="True"/>
|
||||
<field name="description">First wet/production step started. Line is running, real shop time accruing. Triggered by any step with kind in (wet, bake, mask, rack) reaching in_progress or beyond.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_inspected" model="fp.job.workflow.state">
|
||||
<field name="name">Inspected</field>
|
||||
<field name="code">inspected</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="color">green</field>
|
||||
<field name="trigger_default_kinds">final_inspect</field>
|
||||
<field name="block_when_quality_hold" eval="True"/>
|
||||
<field name="description">Final inspection passed AND no open quality hold. Safe to generate CoC + invoice. Blocked while any quality hold is open.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_shipped" model="fp.job.workflow.state">
|
||||
<field name="name">Shipped</field>
|
||||
<field name="code">shipped</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="color">success</field>
|
||||
<field name="trigger_on_delivery_state" eval="True"/>
|
||||
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_done" model="fp.job.workflow.state">
|
||||
<field name="name">Done</field>
|
||||
<field name="code">done</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="color">success</field>
|
||||
<field name="is_terminal" eval="True"/>
|
||||
<field name="trigger_all_steps_done" eval="True"/>
|
||||
<field name="description">Every non-cancelled step is in done/skipped state. Audit trail closed.</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# 19.0.10.24.0 - Plant-view Shop Floor kanban redesign.
|
||||
# Backfill fp.job.step.last_activity_at from write_date so existing
|
||||
# in-progress steps don't immediately trip the S16 idle-warning gate
|
||||
# (8 hours since last activity) on first compute after deploy.
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE fp_job_step
|
||||
SET last_activity_at = write_date
|
||||
WHERE last_activity_at IS NULL
|
||||
""")
|
||||
cr.execute("SELECT count(*) FROM fp_job_step WHERE last_activity_at IS NULL")
|
||||
remaining = cr.fetchone()[0]
|
||||
if remaining:
|
||||
_logger.warning(
|
||||
"%d fp.job.step rows still have NULL last_activity_at after "
|
||||
"backfill (no write_date?). These will trip the idle gate "
|
||||
"on first compute.", remaining,
|
||||
)
|
||||
_logger.info("Backfilled last_activity_at on fp.job.step from write_date")
|
||||
@@ -1,155 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.10.25.0 - Template metadata backfill + recipe-node repointing.
|
||||
|
||||
Runs AFTER fusion_plating's pre-migrate 19.0.21.2.0 (which seeds
|
||||
kind.area_kind and activates derack/demask/gating). At this point:
|
||||
- All kinds have area_kind set.
|
||||
- blast / derack / demask / gating exist and are active.
|
||||
- XML data files have loaded (new templates exist).
|
||||
|
||||
This migration:
|
||||
1. Backfills code / description / icon / kind_id on the ~30 library
|
||||
templates seeded without metadata.
|
||||
2. Repoints existing recipe nodes from wrong kinds to correct ones
|
||||
using unambiguous name patterns.
|
||||
3. Recomputes area_kind on all fp.job.step rows.
|
||||
4. Recomputes active_step_id + card_state on in-flight jobs.
|
||||
|
||||
All phases idempotent - re-running -u is safe.
|
||||
|
||||
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# (name : (code, icon, kind_code, description_snippet))
|
||||
TEMPLATE_BACKFILL = {
|
||||
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
|
||||
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
|
||||
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
|
||||
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
|
||||
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
|
||||
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
|
||||
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
|
||||
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
|
||||
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
|
||||
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
|
||||
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
|
||||
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
|
||||
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
|
||||
'Final Inspection': ('FINAL_INSP_STD', 'fa-check-circle', 'final_inspect', 'Final visual + dimensional QA before packing.'),
|
||||
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
|
||||
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
|
||||
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
|
||||
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
|
||||
'Nickel Strip - Steel Line': ('NI_STRIP_SL', 'fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
|
||||
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle', 'inspect', 'Post-plate inspection - thickness sample + visual.'),
|
||||
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
|
||||
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
|
||||
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step - parts staged ready for the plating line.'),
|
||||
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step - parts staged ready for the next operation.'),
|
||||
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
|
||||
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
|
||||
'Soak Clean': ('SOAK_CLEAN_STD', 'fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
|
||||
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
|
||||
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
|
||||
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
|
||||
}
|
||||
|
||||
# (filter_sql, current_kind_code, new_kind_code, description)
|
||||
# current_kind_code=None means "any kind that isn't the target"
|
||||
NODE_REPOINTING = [
|
||||
("n.name = 'Blasting'", 'other', 'blast', 'Blasting -> blast'),
|
||||
("n.name ILIKE 'Ready %%'", None, 'gating', 'Ready For X -> gating'),
|
||||
# De-Masking: anchored ILIKE (must start with "De-Masking" or
|
||||
# "DeMasking") so we don't match "Ready For De-Masking" which the
|
||||
# earlier 'Ready %' rule already moved to gating. cur_code=None
|
||||
# catches both 'mask' (the 34 lazy/legacy ones) and 'other' (4
|
||||
# older nodes never reclassified after the demask kind was added).
|
||||
# The trailing AND k.code != %s safeguard skips already-correct rows.
|
||||
("(n.name ILIKE 'De-Masking%%' OR n.name ILIKE 'DeMasking%%')", None, 'demask', 'De-Masking -> demask'),
|
||||
("n.name = 'Scheduling'", 'other', 'gating', 'Scheduling -> gating'),
|
||||
("n.name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip -> wet_process'),
|
||||
("n.name ILIKE '%%Pre-Measurement%%' OR n.name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate -> inspect'),
|
||||
]
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Phase 1 - Template metadata backfill. Idempotent: only fills
|
||||
# NULL/empty fields, doesn't overwrite admin edits.
|
||||
Tpl = env['fp.step.template']
|
||||
Kind = env['fp.step.kind']
|
||||
fixed_tpl = 0
|
||||
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
|
||||
tpl = Tpl.search([('name', '=', name)], limit=1)
|
||||
if not tpl:
|
||||
continue
|
||||
vals = {}
|
||||
if not tpl.code:
|
||||
vals['code'] = code
|
||||
cur_desc = (tpl.description or '').strip()
|
||||
if cur_desc in ('', '<p><br></p>', '<p></p>'):
|
||||
vals['description'] = '<p>%s</p>' % desc
|
||||
if tpl.icon == 'fa-cog':
|
||||
vals['icon'] = icon
|
||||
kind = Kind.search([('code', '=', kind_code)], limit=1)
|
||||
if kind and tpl.kind_id.code != kind_code:
|
||||
vals['kind_id'] = kind.id
|
||||
if vals:
|
||||
tpl.write(vals)
|
||||
fixed_tpl += 1
|
||||
_logger.info(
|
||||
'[live-step-fix] template metadata backfilled: %s templates updated',
|
||||
fixed_tpl,
|
||||
)
|
||||
|
||||
# Phase 2 - Recipe node repointing. Idempotent: AND k.code != %s
|
||||
# ensures already-correct rows are skipped on re-run.
|
||||
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
|
||||
params = [new_code]
|
||||
sql = (
|
||||
"UPDATE fusion_plating_process_node n "
|
||||
"SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1) "
|
||||
"FROM fp_step_kind k "
|
||||
"WHERE n.kind_id = k.id "
|
||||
"AND (" + filter_sql + ")"
|
||||
)
|
||||
if cur_code is not None:
|
||||
sql += " AND k.code = %s"
|
||||
params.append(cur_code)
|
||||
sql += " AND k.code != %s"
|
||||
params.append(new_code)
|
||||
cr.execute(sql, params)
|
||||
_logger.info(
|
||||
'[live-step-fix] repointed %s nodes: %s',
|
||||
cr.rowcount, desc,
|
||||
)
|
||||
|
||||
# Phase 3 - Recompute area_kind on every fp.job.step row.
|
||||
steps = env['fp.job.step'].search([])
|
||||
if steps:
|
||||
steps._compute_area_kind()
|
||||
steps.flush_recordset(['area_kind'])
|
||||
_logger.info(
|
||||
'[live-step-fix] recomputed area_kind on %s steps', len(steps),
|
||||
)
|
||||
|
||||
# Phase 4 - Recompute active_step_id + card_state on in-flight jobs.
|
||||
jobs = env['fp.job'].search([
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
])
|
||||
if jobs:
|
||||
jobs._compute_active_step_id()
|
||||
jobs._compute_card_state()
|
||||
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||
_logger.info(
|
||||
'[live-step-fix] recomputed active_step_id + card_state on %s jobs',
|
||||
len(jobs),
|
||||
)
|
||||
@@ -1,234 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.10.26.0 - Recipe cleanup + per-part clone delete.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
|
||||
|
||||
Phases (in order):
|
||||
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
|
||||
duplicate empty ENP-Alum Line sub_process (id 4056).
|
||||
2. Backfill kind on all kind=other nodes via the extended
|
||||
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
|
||||
translation.
|
||||
3. Delete all per-part clone recipes (name ILIKE '% - %').
|
||||
CASCADE handles child nodes; SET NULL handles fp.job /
|
||||
fp.job.step / fp.coating.config / fp.pricing.rule /
|
||||
fp.part.catalog references.
|
||||
4. Recompute fp.job.step.area_kind on all rows.
|
||||
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
|
||||
|
||||
All phases idempotent - re-running -u is safe.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Recipe 3620's ops in the desired final order. Maps the existing node
|
||||
# id (as documented in the spec) to its target sequence. The user
|
||||
# decided mask-first-then-rack (matches the existing De-Masking step's
|
||||
# position between Plating and Bake; de-mask before de-rack would be
|
||||
# illogical).
|
||||
RECIPE_3620_RESEQUENCE = [
|
||||
# (node_id, new_sequence, expected_name)
|
||||
(3853, 10, 'Contract Review'),
|
||||
(3854, 20, 'Incoming Inspection (Standard)'),
|
||||
(3877, 30, 'Masking'),
|
||||
(3855, 40, 'Racking'),
|
||||
(3858, 50, 'Ready for processing'),
|
||||
(3859, 60, 'ENP-Alum Line'),
|
||||
(3861, 70, 'De-Masking'),
|
||||
(3864, 80, 'Oven baking'),
|
||||
(3867, 90, 'De-racking'),
|
||||
(4067, 100, 'Oven bake (Post de-rack)'),
|
||||
(3873, 110, 'Post-plate Inspection'),
|
||||
(3876, 120, 'Final Inspection'),
|
||||
]
|
||||
|
||||
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
|
||||
# children - the real one is id 3859 with E-Nickel Plating as child).
|
||||
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# ============================================================
|
||||
# Phase 1 - Resequence recipe 3620 + delete duplicate sub_process
|
||||
# ============================================================
|
||||
Node = env['fusion.plating.process.node']
|
||||
recipe_3620 = Node.browse(3620).exists()
|
||||
if not recipe_3620:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
|
||||
'skipping resequence phase'
|
||||
)
|
||||
else:
|
||||
# Resequence idempotently - only update if sequence differs.
|
||||
renumbered = 0
|
||||
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
|
||||
node = Node.browse(node_id).exists()
|
||||
if not node:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Recipe 3620: expected node %s '
|
||||
'("%s") not found; skipping',
|
||||
node_id, expected_name,
|
||||
)
|
||||
continue
|
||||
if node.sequence != new_seq:
|
||||
# Skip autoclassify - we only touch sequence here.
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'sequence': new_seq})
|
||||
renumbered += 1
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
|
||||
renumbered,
|
||||
)
|
||||
|
||||
# Delete the empty duplicate ENP-Alum Line sub_process.
|
||||
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
|
||||
if dup:
|
||||
if dup.child_ids:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Duplicate sub_process %s has '
|
||||
'%s children - NOT deleting (safety check). '
|
||||
'Expected an empty node.',
|
||||
dup.id, len(dup.child_ids),
|
||||
)
|
||||
else:
|
||||
dup.unlink()
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Deleted empty duplicate '
|
||||
'ENP-Alum Line sub_process (id %s)',
|
||||
RECIPE_3620_DUPLICATE_TO_DELETE,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 2 - Backfill kind on all kind=other nodes via resolver
|
||||
# ============================================================
|
||||
from odoo.addons.fusion_plating import (
|
||||
fp_resolve_step_kind,
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||
)
|
||||
Kind = env['fp.step.kind']
|
||||
other_kind = Kind.search([('code', '=', 'other')], limit=1)
|
||||
if not other_kind:
|
||||
_logger.error(
|
||||
'[recipe-cleanup] No "other" kind found; skipping kind '
|
||||
'backfill phase'
|
||||
)
|
||||
else:
|
||||
# Cache code -> kind.id so we don't search per-row.
|
||||
kind_by_code = {k.code: k.id for k in Kind.search([])}
|
||||
affected_nodes = Node.search([
|
||||
('kind_id', '=', other_kind.id),
|
||||
('name', '!=', False),
|
||||
('node_type', 'in', ('operation', 'step', 'sub_process')),
|
||||
])
|
||||
fixed = 0
|
||||
for node in affected_nodes:
|
||||
resolver_code = fp_resolve_step_kind(node.name)
|
||||
if not resolver_code:
|
||||
continue
|
||||
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||
if not target_code or target_code not in kind_by_code:
|
||||
continue
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'kind_id': kind_by_code[target_code]})
|
||||
fixed += 1
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
|
||||
'(of %s currently kind=other)',
|
||||
fixed, len(affected_nodes),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 3 - Delete all per-part clone recipes
|
||||
# ============================================================
|
||||
# Identify by name pattern. The configurator names clones
|
||||
# "BASE_NAME - PART_NUMBER Rev X" with an em-dash separator
|
||||
# (U+2014). No base recipe uses em-dash in its name.
|
||||
#
|
||||
# IMPORTANT: delete one-clone-at-a-time with savepoint per clone.
|
||||
# Batch unlink (clone_recipes.unlink()) tripped a PostgreSQL FK
|
||||
# cascade ordering bug on entech (insert-or-update on parent_id
|
||||
# during the cascade chain). Per-clone unlink with intermediate
|
||||
# cleanup avoids that path entirely and lets one bad clone fail
|
||||
# without rolling back the others.
|
||||
clone_recipes = Node.search([
|
||||
('node_type', '=', 'recipe'),
|
||||
('name', 'ilike', '% - %'),
|
||||
])
|
||||
if not clone_recipes:
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: no clone recipes found '
|
||||
'(already deleted on a prior run, or none exist)'
|
||||
)
|
||||
else:
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: deleting %s clone recipes one '
|
||||
'at a time (per-clone savepoint)', len(clone_recipes),
|
||||
)
|
||||
deleted = 0
|
||||
failed = []
|
||||
for clone in clone_recipes:
|
||||
cid, cname = clone.id, clone.name
|
||||
cr.execute('SAVEPOINT delete_clone')
|
||||
try:
|
||||
clone.unlink()
|
||||
cr.execute('RELEASE SAVEPOINT delete_clone')
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
cr.execute('ROLLBACK TO SAVEPOINT delete_clone')
|
||||
failed.append((cid, cname, type(e).__name__, str(e)[:120]))
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Phase 3: failed to delete '
|
||||
'clone %s ("%s"): %s - continuing',
|
||||
cid, cname, type(e).__name__,
|
||||
)
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: deleted %s/%s clones '
|
||||
'(%s failures retained for manual review)',
|
||||
deleted, len(clone_recipes), len(failed),
|
||||
)
|
||||
if failed:
|
||||
for cid, cname, errtype, errmsg in failed:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Phase 3 leftover: id=%s name=%r '
|
||||
'err=%s: %s', cid, cname, errtype, errmsg,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 4 - Recompute area_kind on all fp.job.step rows
|
||||
# ============================================================
|
||||
Step = env['fp.job.step']
|
||||
steps = Step.search([])
|
||||
if steps:
|
||||
steps._compute_area_kind()
|
||||
steps.flush_recordset(['area_kind'])
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
|
||||
len(steps),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 5 - Recompute active_step_id + card_state on in-flight jobs
|
||||
# ============================================================
|
||||
Job = env['fp.job']
|
||||
jobs = Job.search([
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
])
|
||||
if jobs:
|
||||
jobs._compute_active_step_id()
|
||||
jobs._compute_card_state()
|
||||
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
|
||||
'card_state on %s in-flight jobs',
|
||||
len(jobs),
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill new awaiting_cert / awaiting_ship states for mid-flight jobs.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
Rules:
|
||||
- in_progress + all steps terminal + draft cert exists → awaiting_cert
|
||||
- in_progress + all steps terminal + no cert required → awaiting_ship
|
||||
- done jobs LEFT ALONE - historically completed (already shipped)
|
||||
|
||||
Idempotent: re-running on a fresh upgrade is a no-op because no
|
||||
in_progress job will match the all-terminal predicate after the first
|
||||
run. Pass 1 and Pass 2 are mutually exclusive (the cert-existence
|
||||
sub-queries are inverses).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Post-migrate entrypoint - called by Odoo after the module's
|
||||
XML/Python loads on -u of fusion_plating_jobs."""
|
||||
|
||||
# ---- Pass 1: in_progress + all-terminal + draft cert → awaiting_cert
|
||||
cr.execute("""
|
||||
UPDATE fp_job
|
||||
SET state = 'awaiting_cert'
|
||||
WHERE id IN (
|
||||
SELECT j.id
|
||||
FROM fp_job j
|
||||
JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id
|
||||
HAVING count(*) FILTER (
|
||||
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||
) = 0
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fp_certificate c
|
||||
WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
|
||||
);
|
||||
""")
|
||||
n_cert = cr.rowcount
|
||||
_logger.info(
|
||||
"post-migrate 19.0.11.0.0: %d jobs migrated to awaiting_cert", n_cert,
|
||||
)
|
||||
|
||||
# ---- Pass 2: in_progress + all-terminal + no cert → awaiting_ship
|
||||
cr.execute("""
|
||||
UPDATE fp_job
|
||||
SET state = 'awaiting_ship'
|
||||
WHERE id IN (
|
||||
SELECT j.id
|
||||
FROM fp_job j
|
||||
JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id
|
||||
HAVING count(*) FILTER (
|
||||
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||
) = 0
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_certificate c
|
||||
WHERE c.x_fc_job_id = fp_job.id
|
||||
AND c.state IN ('draft', 'issued')
|
||||
);
|
||||
""")
|
||||
n_ship = cr.rowcount
|
||||
_logger.info(
|
||||
"post-migrate 19.0.11.0.0: %d jobs migrated to awaiting_ship", n_ship,
|
||||
)
|
||||
|
||||
# ---- Card_state recompute for affected rows (stored compute) ----
|
||||
if n_cert or n_ship:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
affected = env['fp.job'].search([
|
||||
('state', 'in', ('awaiting_cert', 'awaiting_ship')),
|
||||
])
|
||||
# Bust cache then read-to-recompute via @api.depends.
|
||||
affected.invalidate_recordset(['card_state', 'mini_timeline_json'])
|
||||
affected.mapped('card_state')
|
||||
affected.mapped('mini_timeline_json')
|
||||
_logger.info(
|
||||
"post-migrate 19.0.11.0.0: card_state recomputed on %d jobs",
|
||||
len(affected),
|
||||
)
|
||||
@@ -1,89 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
#
|
||||
# Phase 1 (Sub 11) - relocate ir.model.data XML IDs from
|
||||
# fusion_plating_bridge_mrp to fusion_plating_jobs for the four
|
||||
# models that moved: fp.work.role, fp.operator.proficiency,
|
||||
# fp.qc.checklist.template (+line), fp.job.consumption.
|
||||
#
|
||||
# Pre-migration so Odoo's normal load pass sees the records under the
|
||||
# new module owner, not as orphans pending deletion.
|
||||
#
|
||||
# Idempotent - `ON CONFLICT DO NOTHING` skips rows already migrated.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return # Fresh install - nothing to migrate
|
||||
|
||||
moves = [
|
||||
# (xmlid pattern, list of model identifiers to move)
|
||||
('model_fp_job_consumption',),
|
||||
# ACL records (csv:id values get prefixed with the owning module)
|
||||
('access_fp_job_consumption_%',),
|
||||
]
|
||||
for (pat,) in moves:
|
||||
cr.execute(
|
||||
"""
|
||||
UPDATE ir_model_data
|
||||
SET module = 'fusion_plating_jobs'
|
||||
WHERE module = 'fusion_plating_bridge_mrp'
|
||||
AND name LIKE %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ir_model_data d2
|
||||
WHERE d2.module = 'fusion_plating_jobs'
|
||||
AND d2.name = ir_model_data.name
|
||||
)
|
||||
""",
|
||||
(pat,),
|
||||
)
|
||||
if cr.rowcount:
|
||||
_logger.info(
|
||||
"Sub 11: re-keyed %d ir.model.data rows matching %s -> fusion_plating_jobs",
|
||||
cr.rowcount, pat,
|
||||
)
|
||||
|
||||
# Views, actions, menus that the old module created
|
||||
view_patterns = [
|
||||
'view_fp_job_consumption_%',
|
||||
'action_fp_job_consumption%',
|
||||
'menu_fp_job_consumption%',
|
||||
]
|
||||
for pat in view_patterns:
|
||||
cr.execute(
|
||||
"""
|
||||
UPDATE ir_model_data
|
||||
SET module = 'fusion_plating_jobs'
|
||||
WHERE module = 'fusion_plating_bridge_mrp'
|
||||
AND name LIKE %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ir_model_data d2
|
||||
WHERE d2.module = 'fusion_plating_jobs'
|
||||
AND d2.name = ir_model_data.name
|
||||
)
|
||||
""",
|
||||
(pat,),
|
||||
)
|
||||
if cr.rowcount:
|
||||
_logger.info(
|
||||
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_jobs",
|
||||
cr.rowcount, pat,
|
||||
)
|
||||
|
||||
# Phase 1 swap: fp.job.consumption columns. Drop the legacy
|
||||
# MRP-pointing columns (production_id, workorder_id) from the
|
||||
# already-existing table - there are zero rows referencing MRP, and
|
||||
# the new model declares job_id / step_id instead.
|
||||
cr.execute(
|
||||
"""
|
||||
ALTER TABLE fp_job_consumption
|
||||
DROP COLUMN IF EXISTS production_id,
|
||||
DROP COLUMN IF EXISTS workorder_id
|
||||
"""
|
||||
)
|
||||
_logger.info("Sub 11: dropped MRP columns on fp_job_consumption")
|
||||
@@ -1,47 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 2 of the native plating job model migration. Models are added
|
||||
# task-by-task in Tasks 2.2 onwards.
|
||||
|
||||
from . import fp_job_workflow_state # Sub 14 - must load before fp_job (FK target)
|
||||
from . import fp_job
|
||||
from . import fp_job_sticker
|
||||
from . import fp_job_step
|
||||
from . import fp_job_masking
|
||||
from . import fp_job_node_override
|
||||
from . import fp_portal_job
|
||||
from . import account_move
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import res_users
|
||||
|
||||
# Phase 3 - parallel job/step links on dependent modules' models.
|
||||
from . import fp_batch
|
||||
from . import fp_quality_hold
|
||||
from . import fp_certificate
|
||||
from . import fp_thickness_reading
|
||||
from . import fp_delivery
|
||||
from . import fp_racking_inspection
|
||||
from . import fp_receiving
|
||||
|
||||
# Phase 4 - light refactors batch B (notifications, KPI source tag).
|
||||
from . import fp_notification_trigger
|
||||
from . import fusion_plating_kpi_value
|
||||
|
||||
# Phase 5 - Job Margin report.
|
||||
from . import report_fp_job_margin
|
||||
|
||||
# Phase 1 of MRP cut-out (Sub 11) - relocated from fusion_plating_bridge_mrp.
|
||||
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
|
||||
# back on jobs without a cycle.)
|
||||
from . import fp_job_consumption
|
||||
|
||||
# Multi-rack splitting at Racking (Phase 1) - jobs-side extension of
|
||||
# fp.rack.load (core model in fusion_plating) + fp.job rollups.
|
||||
from . import fp_job_rack
|
||||
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
|
||||
# hr.employee shop-roles inherit live in fusion_plating core so every
|
||||
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a
|
||||
# transitive dep on jobs.
|
||||
Binary file not shown.
@@ -1,150 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""account.move overrides for Fusion Plating:
|
||||
|
||||
1. Block direct creation of out_invoice / out_refund for ALL users
|
||||
including administrators. The only legal entry points are:
|
||||
* sale.order._create_invoices() - sets context fp_from_so_invoice=True
|
||||
* manual create() with invoice_origin matching an existing sale.order.name
|
||||
|
||||
2. Once a customer move is created via a legitimate path, derive its
|
||||
name from the SO's parent number (IN-30000 / IN-30000-02 for
|
||||
invoices, CN-30000 / CN-30000-02 for credit notes). Per the
|
||||
2026-05-12 parent-number hierarchy design.
|
||||
|
||||
3. On post, link the invoice back to its fp.job's portal job (mark
|
||||
complete, stamp invoice_ref). Pre-existing behaviour, preserved.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
CUSTOMER_TYPES = ('out_invoice', 'out_refund', 'out_receipt')
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = ['account.move', 'fp.parent.numbered.mixin']
|
||||
|
||||
# =================================================================
|
||||
# Parent-numbered mixin hooks
|
||||
# =================================================================
|
||||
def _fp_parent_sale_order(self):
|
||||
"""Find linked SO via SO context flag (set by _create_invoices),
|
||||
or fall back to invoice_origin name match, then to the reversed
|
||||
entry's SO (for the Add Credit Note path where invoice_origin
|
||||
has copy=False and doesn't survive the move.copy())."""
|
||||
so_id = self.env.context.get('fp_invoice_source_so_id')
|
||||
if so_id:
|
||||
so = self.env['sale.order'].browse(so_id).exists()
|
||||
if so:
|
||||
return so
|
||||
if self.invoice_origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', self.invoice_origin)], limit=1,
|
||||
)
|
||||
if so:
|
||||
return so
|
||||
# Reversal path: read the parent move's SO link so the credit
|
||||
# note's name flows from the same parent number as the invoice
|
||||
# it's reversing.
|
||||
if self.reversed_entry_id:
|
||||
parent_so = self.reversed_entry_id._fp_parent_sale_order()
|
||||
if parent_so:
|
||||
return parent_so
|
||||
return self.env['sale.order']
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'CN' if self.move_type == 'out_refund' else 'IN'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_cn_count' if self.move_type == 'out_refund' else 'x_fc_pn_invoice_count'
|
||||
|
||||
# =================================================================
|
||||
# Create override: block off-flow + assign parent-derived name
|
||||
# =================================================================
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
self._fp_validate_customer_invoice(vals)
|
||||
moves = super().create(vals_list)
|
||||
for mv in moves:
|
||||
if mv.move_type in CUSTOMER_TYPES:
|
||||
mv._fp_assign_parent_name()
|
||||
return moves
|
||||
|
||||
@api.model
|
||||
def _fp_validate_customer_invoice(self, vals):
|
||||
"""Refuse out_invoice / out_refund / out_receipt creation that
|
||||
didn't come through the SO workflow. Applies to ALL users
|
||||
including admins."""
|
||||
mtype = vals.get('move_type', 'entry')
|
||||
if mtype not in CUSTOMER_TYPES:
|
||||
return
|
||||
if self.env.context.get('fp_from_so_invoice'):
|
||||
return
|
||||
origin = (vals.get('invoice_origin') or '').strip()
|
||||
if origin and self.env['sale.order'].sudo().search_count(
|
||||
[('name', '=', origin)]
|
||||
):
|
||||
return
|
||||
# Credit-note / reversal path: Odoo's "Add Credit Note" wizard
|
||||
# calls move.copy() with reversed_entry_id set in the defaults,
|
||||
# but invoice_origin has copy=False on the standard field so
|
||||
# it doesn't survive the copy. Allow reversals through as long
|
||||
# as the reversed entry is itself a customer-facing move (which
|
||||
# means it already went through this validator at original
|
||||
# creation time - the audit trail is intact).
|
||||
reversed_id = vals.get('reversed_entry_id')
|
||||
if reversed_id:
|
||||
parent = self.env['account.move'].sudo().browse(reversed_id)
|
||||
if parent.exists() and parent.move_type in CUSTOMER_TYPES:
|
||||
return
|
||||
raise UserError(_(
|
||||
'Customer invoices, credit notes, and receipts must be '
|
||||
'created from a Sale Order. Open the originating SO and '
|
||||
'use the Create Invoice / Add Credit Note action.\n\n'
|
||||
'This rule applies to all users including administrators. '
|
||||
'It is enforced to keep the parent-number audit trail '
|
||||
'intact (see fusion_plating numbering policy).'
|
||||
))
|
||||
|
||||
# =================================================================
|
||||
# Post hook: link the invoice to its fp.job's portal job
|
||||
# =================================================================
|
||||
def action_post(self):
|
||||
result = super().action_post()
|
||||
for invoice in self.filtered(
|
||||
lambda m: m.move_type in CUSTOMER_TYPES
|
||||
):
|
||||
invoice._fp_link_to_job()
|
||||
return result
|
||||
|
||||
def _fp_link_to_job(self):
|
||||
self.ensure_one()
|
||||
if not self.invoice_origin:
|
||||
return
|
||||
Job = self.env['fp.job'].sudo()
|
||||
SO = self.env['sale.order'].sudo()
|
||||
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
|
||||
if not so:
|
||||
return
|
||||
job = Job.search([('sale_order_id', '=', so.id)], limit=1)
|
||||
if not job or not job.portal_job_id:
|
||||
return
|
||||
portal = job.portal_job_id
|
||||
if 'invoice_ref' in portal._fields:
|
||||
portal.invoice_ref = self.name
|
||||
# Recompute state via the central helper - it'll only land on
|
||||
# 'complete' if the WO is actually done AND the shipment is
|
||||
# delivered. Posting an invoice early no longer skips the floor.
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal._fp_recompute_portal_state()
|
||||
_logger.info(
|
||||
'Invoice %s linked to fp.job %s portal %s',
|
||||
self.name, job.name, portal.name,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 - parallel job/step links on fusion.plating.batch.
|
||||
# The legacy workorder_id link to mrp.workorder stays in place.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionPlatingBatch(models.Model):
|
||||
_inherit = 'fusion.plating.batch'
|
||||
|
||||
x_fc_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='Plating Step',
|
||||
index=True,
|
||||
help='Native fp.job.step link. Coexists with the legacy '
|
||||
'workorder_id link to mrp.workorder.',
|
||||
)
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
related='x_fc_step_id.job_id',
|
||||
store=True,
|
||||
string='Work Order',
|
||||
)
|
||||
@@ -1,282 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 - parallel job link on fp.certificate.
|
||||
# Coexists with bridge_mrp's production_id link.
|
||||
#
|
||||
# v19.0.6.20.0 - surface the Fischerscope PDF on the cert form so
|
||||
# operators can SEE that the thickness report will be (or has been)
|
||||
# merged into the CoC. The merge logic itself lives in
|
||||
# fusion_plating_certificates/models/fp_certificate.py - this file
|
||||
# only adds the human-readable indicators.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpCertificate(models.Model):
|
||||
_inherit = 'fp.certificate'
|
||||
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
string='Work Order',
|
||||
index=True,
|
||||
help="Native fp.job link. Coexists with bridge_mrp's production_id.",
|
||||
)
|
||||
|
||||
# ---- Fischerscope thickness-PDF visibility (S19) ---------------------
|
||||
# These three fields are computed from the linked job's QC checks so
|
||||
# the cert form can show the operator BEFORE issuing whether a
|
||||
# Fischerscope report is on file and will be appended as page 2.
|
||||
x_fc_thickness_qc_id = fields.Many2one(
|
||||
'fusion.plating.quality.check',
|
||||
string='Linked QC (Thickness)',
|
||||
compute='_compute_fischer_visibility',
|
||||
help='Quality check on the linked plating job that has a '
|
||||
'Fischerscope / XDAL 600 thickness PDF uploaded. Used to '
|
||||
'merge that PDF into the CoC on Issue.',
|
||||
)
|
||||
x_fc_thickness_pdf_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Fischerscope PDF',
|
||||
compute='_compute_fischer_visibility',
|
||||
help='Thickness report PDF that will be appended as page 2 of '
|
||||
'the CoC when the certificate is issued.',
|
||||
)
|
||||
x_fc_thickness_status = fields.Selection(
|
||||
[
|
||||
('none', 'No PDF Uploaded'),
|
||||
('pending', 'Will Append on Issue'),
|
||||
('merged', 'Merged into CoC'),
|
||||
],
|
||||
string='Thickness Report',
|
||||
compute='_compute_fischer_visibility',
|
||||
help='none = QC has no Fischerscope upload · '
|
||||
'pending = will be appended when Issue is clicked · '
|
||||
'merged = already in the issued CoC PDF',
|
||||
)
|
||||
|
||||
@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
|
||||
empty_att = self.env['ir.attachment']
|
||||
for rec in self:
|
||||
qc = empty_qc
|
||||
pdf = empty_att
|
||||
status = 'none'
|
||||
# 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([
|
||||
('job_id', '=', rec.x_fc_job_id.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', rec.x_fc_job_id.id),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if qc and qc.thickness_report_pdf_id:
|
||||
pdf = qc.thickness_report_pdf_id
|
||||
if rec.state == 'issued' and rec.attachment_id:
|
||||
status = 'merged'
|
||||
else:
|
||||
status = 'pending'
|
||||
rec.x_fc_thickness_qc_id = qc or empty_qc
|
||||
rec.x_fc_thickness_pdf_id = pdf or empty_att
|
||||
rec.x_fc_thickness_status = status
|
||||
|
||||
def action_view_thickness_qc(self):
|
||||
"""Smart-button target - open the linked QC for inspection."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_thickness_qc_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_thickness_qc_id.name,
|
||||
'res_model': 'fusion.plating.quality.check',
|
||||
'res_id': self.x_fc_thickness_qc_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_job(self):
|
||||
"""Smart-button target - open the linked plating job."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_job_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_job_id.name,
|
||||
'res_model': 'fp.job',
|
||||
'res_id': self.x_fc_job_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ---- Parse-on-upload for the cert-form Fischerscope field (2026-05-28)
|
||||
# The Issue Certs wizard parses .doc/.docx/RTF Fischerscope exports into
|
||||
# readings + metadata + microscope image. Dropping the same file straight
|
||||
# onto the cert form's x_fc_local_thickness_pdf field did nothing - it
|
||||
# just stored the bytes. These hooks give the form the SAME behaviour as
|
||||
# the wizard: on save, a non-PDF upload is parsed and relocated to the
|
||||
# evidence field (a real PDF is left in place to merge as page 2).
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
recs = super().create(vals_list)
|
||||
for rec in recs:
|
||||
if (rec.x_fc_local_thickness_pdf
|
||||
and not self.env.context.get('fp_skip_thickness_parse')):
|
||||
rec._fp_parse_local_thickness_upload()
|
||||
return recs
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if (vals.get('x_fc_local_thickness_pdf')
|
||||
and not self.env.context.get('fp_skip_thickness_parse')):
|
||||
for rec in self:
|
||||
rec._fp_parse_local_thickness_upload()
|
||||
return res
|
||||
|
||||
def _fp_parse_local_thickness_upload(self):
|
||||
"""Parse a Fischerscope .doc/.docx/RTF dropped on the cert form's
|
||||
x_fc_local_thickness_pdf field, exactly like the Issue Certs wizard:
|
||||
extract readings → thickness_reading_ids, header metadata →
|
||||
x_fc_thickness_* fields, microscope image → x_fc_thickness_image_id,
|
||||
then relocate the non-PDF source to x_fc_local_thickness_evidence_id
|
||||
and clear the PDF field (so the page-2 merge doesn't choke on it).
|
||||
|
||||
A real PDF is left in place - it merges as page 2 of the CoC on
|
||||
Issue and carries no parseable readings. Unknown non-PDF types are
|
||||
left untouched.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime
|
||||
self.ensure_one()
|
||||
if not self.x_fc_local_thickness_pdf:
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.x_fc_local_thickness_pdf)
|
||||
except Exception:
|
||||
return
|
||||
# Real PDF → leave it (merges as page 2). XDAL 600 names RTF files
|
||||
# ".doc"; detect by magic bytes, not extension (see CLAUDE.md).
|
||||
if raw[:4] == b'%PDF':
|
||||
return
|
||||
name = (self.x_fc_local_thickness_pdf_filename or '').lower()
|
||||
is_rtf = raw[:5] == b'{\\rtf'
|
||||
is_docx = name.endswith('.docx')
|
||||
if not (is_rtf or is_docx):
|
||||
return # unknown non-PDF - don't guess
|
||||
|
||||
from ..wizards.fp_cert_issue_wizard import (
|
||||
_fp_parse_fischerscope_rtf, _fp_parse_fischerscope_docx,
|
||||
_fp_extract_rtf_images, _fp_pick_microscope_image,
|
||||
)
|
||||
parsed = (_fp_parse_fischerscope_rtf(raw) if is_rtf
|
||||
else _fp_parse_fischerscope_docx(raw))
|
||||
|
||||
vals = {}
|
||||
for fname, fval in (
|
||||
('x_fc_thickness_operator', parsed.get('operator')),
|
||||
('x_fc_thickness_product', parsed.get('product')),
|
||||
('x_fc_thickness_directory', parsed.get('directory')),
|
||||
('x_fc_thickness_application', parsed.get('application')),
|
||||
('x_fc_thickness_measuring_time_sec',
|
||||
parsed.get('measuring_time_sec') or 0),
|
||||
('x_fc_thickness_equipment',
|
||||
parsed.get('equipment') or 'Fischerscope XDAL 600'),
|
||||
('x_fc_thickness_source_filename',
|
||||
self.x_fc_local_thickness_pdf_filename or ''),
|
||||
):
|
||||
if fname in self._fields and fval:
|
||||
vals[fname] = fval
|
||||
|
||||
date_str = (parsed.get('date_str') or '').strip()
|
||||
time_str = (parsed.get('time_str') or '').strip()
|
||||
if date_str and 'x_fc_thickness_datetime' in self._fields:
|
||||
combined = ('%s %s' % (date_str, time_str)).strip()
|
||||
for fmt in (
|
||||
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
|
||||
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y',
|
||||
):
|
||||
try:
|
||||
vals['x_fc_thickness_datetime'] = datetime.strptime(
|
||||
combined, fmt,
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Readings - replace any existing set with the freshly-parsed rows
|
||||
# (the uploaded report is authoritative for this cert).
|
||||
readings = parsed.get('readings') or []
|
||||
Reading = self.env.get('fp.thickness.reading')
|
||||
if readings and Reading is not None:
|
||||
calibration = parsed.get('calibration') or ''
|
||||
cmds = [(5, 0, 0)]
|
||||
for i, (nip, ni, p) in enumerate(readings):
|
||||
rvals = {'nip_mils': nip, 'ni_percent': ni, 'p_percent': p}
|
||||
if 'reading_number' in Reading._fields:
|
||||
rvals['reading_number'] = i + 1
|
||||
if calibration and 'calibration_std_ref' in Reading._fields:
|
||||
rvals['calibration_std_ref'] = calibration
|
||||
cmds.append((0, 0, rvals))
|
||||
vals['thickness_reading_ids'] = cmds
|
||||
|
||||
# Relocate the non-PDF source to the evidence slot + clear the PDF
|
||||
# field (mirrors the wizard's non-PDF end state).
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.x_fc_local_thickness_pdf_filename or 'fischerscope-report',
|
||||
'type': 'binary',
|
||||
'datas': self.x_fc_local_thickness_pdf,
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
})
|
||||
if 'x_fc_local_thickness_evidence_id' in self._fields:
|
||||
vals['x_fc_local_thickness_evidence_id'] = att.id
|
||||
vals['x_fc_local_thickness_pdf'] = False
|
||||
vals['x_fc_local_thickness_pdf_filename'] = False
|
||||
|
||||
# Microscope image (RTF only - .docx images need a different path).
|
||||
if is_rtf and 'x_fc_thickness_image_id' in self._fields:
|
||||
try:
|
||||
pngs = _fp_extract_rtf_images(raw)
|
||||
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
|
||||
if img_bytes:
|
||||
img_att = self.env['ir.attachment'].sudo().create({
|
||||
'name': '%s-microscope.png' % (
|
||||
(self.x_fc_local_thickness_pdf_filename
|
||||
or 'fischerscope').rsplit('.', 1)[0]
|
||||
),
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(img_bytes),
|
||||
'mimetype': 'image/png',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
})
|
||||
vals['x_fc_thickness_image_id'] = img_att.id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.with_context(fp_skip_thickness_parse=True).write(vals)
|
||||
from markupsafe import Markup
|
||||
from odoo import _
|
||||
self.message_post(body=Markup(_(
|
||||
'Fischerscope file <b>%s</b> parsed from the cert form: '
|
||||
'%d reading(s) extracted.'
|
||||
)) % (
|
||||
self.x_fc_thickness_source_filename or name or 'unnamed',
|
||||
len(readings),
|
||||
))
|
||||
@@ -1,19 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 - parallel job link on fusion.plating.delivery.
|
||||
# Coexists with the legacy job_ref Char.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionPlatingDelivery(models.Model):
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
string='Work Order',
|
||||
index=True,
|
||||
help='Native fp.job link. Coexists with the legacy job_ref Char.',
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Phase 1 (Sub 11) - relocated from fusion_plating_bridge_mrp.
|
||||
# MRP-flavoured fields (production_id, workorder_id) replaced by their
|
||||
# native fp.job / fp.job.step equivalents.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpJobConsumption(models.Model):
|
||||
"""A single consumable drawdown charged to a plating job.
|
||||
|
||||
Sources include bath replenishment applied against a job, masking tape
|
||||
rolls, PPE, nickel salts - anything that has a cost and should roll
|
||||
into job costing.
|
||||
|
||||
Kept deliberately lightweight: one row per event, cost derived from
|
||||
`product.standard_price` at log time (snapshot, not reactive).
|
||||
"""
|
||||
_name = 'fp.job.consumption'
|
||||
_description = 'Fusion Plating - Job Consumption'
|
||||
_order = 'logged_date desc, id desc'
|
||||
|
||||
job_id = fields.Many2one(
|
||||
'fp.job', string='Work Order',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
step_id = fields.Many2one(
|
||||
'fp.job.step', string='Job Step',
|
||||
domain="[('job_id', '=', job_id)]",
|
||||
ondelete='set null',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Product', required=True,
|
||||
domain="[('sale_ok', '=', False)]",
|
||||
)
|
||||
product_name = fields.Char(
|
||||
string='Product Name (snapshot)',
|
||||
help='Free-text product label if no inventory product is linked.',
|
||||
)
|
||||
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
|
||||
uom_id = fields.Many2one(
|
||||
'uom.uom', string='UoM',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
unit_cost = fields.Monetary(
|
||||
string='Unit Cost (snapshot)', currency_field='currency_id',
|
||||
help='Taken from product.standard_price at log time.',
|
||||
)
|
||||
total_cost = fields.Monetary(
|
||||
string='Total Cost', currency_field='currency_id',
|
||||
compute='_compute_total_cost', store=True,
|
||||
)
|
||||
logged_date = fields.Datetime(
|
||||
string='Logged', default=fields.Datetime.now,
|
||||
)
|
||||
logged_by_id = fields.Many2one(
|
||||
'res.users', string='Logged By', default=lambda self: self.env.user,
|
||||
)
|
||||
source = fields.Selection(
|
||||
[('replenishment', 'Bath Replenishment'),
|
||||
('masking', 'Masking Material'),
|
||||
('ppe', 'PPE / Consumables'),
|
||||
('chemistry', 'Process Chemistry'),
|
||||
('other', 'Other')],
|
||||
string='Source', default='other', required=True,
|
||||
)
|
||||
replenishment_id = fields.Many2one(
|
||||
'fusion.plating.bath.replenishment.suggestion',
|
||||
string='Replenishment Suggestion',
|
||||
ondelete='set null',
|
||||
)
|
||||
notes = fields.Char(string='Notes')
|
||||
|
||||
@api.depends('quantity', 'unit_cost')
|
||||
def _compute_total_cost(self):
|
||||
for rec in self:
|
||||
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
|
||||
|
||||
@api.depends('product_id', 'product_name', 'quantity', 'job_id')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
label = rec.product_id.display_name or rec.product_name or 'Consumption'
|
||||
qty = ('%g' % rec.quantity) if rec.quantity else ''
|
||||
job = rec.job_id.name or ''
|
||||
bits = [label]
|
||||
if qty:
|
||||
bits.append('×' + qty)
|
||||
if job:
|
||||
bits.append('(%s)' % job)
|
||||
rec.display_name = ' '.join(bits)
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product(self):
|
||||
if self.product_id:
|
||||
self.product_name = self.product_id.display_name
|
||||
self.unit_cost = self.product_id.standard_price or 0.0
|
||||
self.uom_id = self.product_id.uom_id or False
|
||||
@@ -1,36 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Masking reference attachments - captured at Express order entry, surfaced
|
||||
on the job's masking step (operator workstation) and rolled up to the job
|
||||
form (office). Populated by sale.order.line._fp_apply_express_overrides_to_job.
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
_inherit = 'fp.job.step'
|
||||
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_job_step_masking_att_rel', 'step_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Reference image(s)/PDF(s) of what to mask, attached at order '
|
||||
'entry (Express) and shown to the operator on the masking step.',
|
||||
)
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
compute='_compute_masking_attachment_ids',
|
||||
string='Masking References',
|
||||
help='All masking reference files across this job\'s masking steps.',
|
||||
)
|
||||
|
||||
@api.depends('step_ids.x_fc_masking_attachment_ids')
|
||||
def _compute_masking_attachment_ids(self):
|
||||
for job in self:
|
||||
atts = job.step_ids.mapped('x_fc_masking_attachment_ids')
|
||||
job.x_fc_masking_attachment_ids = [(6, 0, atts.ids)]
|
||||
@@ -1,49 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# fp.job.node.override - per-job opt-in/out decisions for opt_in/opt_out
|
||||
# recipe nodes. Mirrors fusion.plating.job.node.override from bridge_mrp,
|
||||
# but bound to fp.job instead of mrp.production.
|
||||
#
|
||||
# bridge_mrp keeps its version alive so legacy MO-flow keeps working.
|
||||
# Both coexist during the migration period.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpJobNodeOverride(models.Model):
|
||||
_name = 'fp.job.node.override'
|
||||
_description = 'Work Order Recipe Node Override'
|
||||
_order = 'job_id, node_id'
|
||||
|
||||
job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Node',
|
||||
required=True,
|
||||
domain="[('opt_in_out', 'in', ('opt_in', 'opt_out'))]",
|
||||
)
|
||||
included = fields.Boolean(
|
||||
string='Included',
|
||||
default=True,
|
||||
help='When True, this opt-in/out node is included in step generation.',
|
||||
)
|
||||
|
||||
@api.depends('job_id', 'node_id', 'included')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
job = rec.job_id.display_name or '(no job)'
|
||||
node = rec.node_id.display_name or '(no node)'
|
||||
tag = 'included' if rec.included else 'excluded'
|
||||
rec.display_name = '%s · %s [%s]' % (job, node, tag)
|
||||
|
||||
_unique_job_node = models.Constraint(
|
||||
'unique(job_id, node_id)',
|
||||
'A job can only have one override per recipe node.',
|
||||
)
|
||||
@@ -1,166 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Multi-rack splitting at Racking - Phase 1 jobs-module extension.
|
||||
# Core models live in fusion_plating/models/fp_rack_load.py. This file owns
|
||||
# everything that touches jobs-module fields (fp.job.step.area_kind,
|
||||
# fp.job.part_catalog_id) and the racking-step detection (_fp_is_racking_step).
|
||||
# Spec/plan: docs/superpowers/{specs,plans}/2026-06-03-racking-multi-rack-*.md
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpRackLoad(models.Model):
|
||||
_inherit = 'fp.rack.load'
|
||||
|
||||
current_area_kind = fields.Char(
|
||||
string='Current Area', compute='_compute_current_area_kind', store=True)
|
||||
|
||||
@api.depends('current_step_id.area_kind')
|
||||
def _compute_current_area_kind(self):
|
||||
for load in self:
|
||||
load.current_area_kind = load.current_step_id.area_kind or False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Racking-step resolution + the "total parts available to rack"
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _fp_racking_step_for(self, job):
|
||||
# Detect the racking step by area_kind == 'racking' (the corrected
|
||||
# classification), NOT _fp_is_racking_step() - the latter keys off the
|
||||
# step's kind, and de-racking steps are frequently mis-tagged
|
||||
# kind='racking' in the data, which would wrongly match De-Racking.
|
||||
return job.step_ids.filtered(lambda s: s.area_kind == 'racking')[:1]
|
||||
|
||||
@api.model
|
||||
def _fp_racking_total(self, job):
|
||||
step = self._fp_racking_step_for(job)
|
||||
if step and step.qty_at_step:
|
||||
return int(step.qty_at_step)
|
||||
return int(job.qty or 0)
|
||||
|
||||
@api.model
|
||||
def _fp_job_loads(self, job):
|
||||
"""Active (not unracked/cancelled) loads carrying this job's parts."""
|
||||
return self.search([
|
||||
('line_ids.job_id', '=', job.id),
|
||||
('state', 'in', ('loading', 'loaded', 'running')),
|
||||
], order='id')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Division API (operator's split + manual override)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _fp_split_job(self, job, n):
|
||||
"""(Re)create n loads for `job`, equal split of the racking total.
|
||||
|
||||
Drops existing unmoved 'loading' loads first. Moved/assigned loads are
|
||||
left alone (can't re-split parts that already advanced)."""
|
||||
total = self._fp_racking_total(job)
|
||||
self._fp_job_loads(job).filtered(
|
||||
lambda l: l.state == 'loading' and not l.current_step_id).unlink()
|
||||
qtys = self._fp_equal_split(total, max(int(n), 1))
|
||||
loads = self.browse()
|
||||
for q in qtys:
|
||||
loads |= self.create({
|
||||
'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})],
|
||||
})
|
||||
return loads
|
||||
|
||||
@api.model
|
||||
def _fp_ensure_seeded(self, job):
|
||||
"""Default state: one rack carrying all the parts."""
|
||||
if not self._fp_job_loads(job):
|
||||
self._fp_split_job(job, 1)
|
||||
return self._fp_job_loads(job)
|
||||
|
||||
@api.model
|
||||
def _fp_add_rack(self, job):
|
||||
return self._fp_split_job(job, len(self._fp_job_loads(job)) + 1)
|
||||
|
||||
@api.model
|
||||
def _fp_divide_equally(self, job):
|
||||
return self._fp_split_job(job, max(len(self._fp_job_loads(job)), 1))
|
||||
|
||||
def _fp_set_qty(self, qty):
|
||||
"""Manual override of a single load's quantity (must not exceed the
|
||||
job's available parts across all its loads)."""
|
||||
self.ensure_one()
|
||||
line = self.line_ids[:1]
|
||||
if not line:
|
||||
raise UserError(_('This rack has no work order line.'))
|
||||
job = line.job_id
|
||||
total = self._fp_racking_total(job)
|
||||
other = sum((self._fp_job_loads(job) - self).mapped('qty_total'))
|
||||
if other + int(qty) > total:
|
||||
raise UserError(
|
||||
_('Assigned %(a)s exceeds the %(t)s parts available to rack.')
|
||||
% {'a': other + int(qty), 't': total})
|
||||
line.qty = int(qty)
|
||||
|
||||
def _fp_remove_rack(self):
|
||||
self.ensure_one()
|
||||
if self.current_step_id:
|
||||
raise UserError(_('Cannot remove a rack that has already moved.'))
|
||||
self.unlink()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Independent movement + de-racking
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_advance_to(self, to_step):
|
||||
"""Move these rack-loads to `to_step`: one move row per line (per WO),
|
||||
carrying the rack + line qty, then update position/state."""
|
||||
Move = self.env['fp.job.step.move']
|
||||
for load in self:
|
||||
from_step = load.current_step_id
|
||||
for line in load.line_ids:
|
||||
Move.create({
|
||||
'job_id': line.job_id.id,
|
||||
'from_step_id': from_step.id if from_step else False,
|
||||
'to_step_id': to_step.id,
|
||||
'qty_moved': line.qty,
|
||||
'rack_id': load.rack_id.id if load.rack_id else False,
|
||||
'transfer_type': 'step',
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
load.current_step_id = to_step
|
||||
load.state = 'running'
|
||||
|
||||
def _fp_unrack(self):
|
||||
"""De-Racking: free the physical rack; each line's parts continue in
|
||||
its own job's flow (the per-line moves already attributed qty)."""
|
||||
for load in self:
|
||||
load.state = 'unracked'
|
||||
if load.rack_id:
|
||||
load.rack_id.racking_state = 'empty'
|
||||
|
||||
|
||||
class FpRackLoadLine(models.Model):
|
||||
_inherit = 'fp.rack.load.line'
|
||||
|
||||
part_catalog_id = fields.Many2one(
|
||||
related='job_id.part_catalog_id', store=True, string='Part')
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
rack_load_line_ids = fields.One2many(
|
||||
'fp.rack.load.line', 'job_id', string='Rack Loads')
|
||||
qty_racked = fields.Integer(
|
||||
string='Parts Racked', compute='_compute_qty_racked')
|
||||
qty_unracked = fields.Integer(
|
||||
string='Parts Unassigned', compute='_compute_qty_racked')
|
||||
|
||||
@api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
|
||||
def _compute_qty_racked(self):
|
||||
Load = self.env['fp.rack.load']
|
||||
for job in self:
|
||||
active = job.rack_load_line_ids.filtered(
|
||||
lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
|
||||
job.qty_racked = sum(active.mapped('qty'))
|
||||
total = Load._fp_racking_total(job)
|
||||
job.qty_unracked = max(total - job.qty_racked, 0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,113 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Display helpers for the redesigned job stickers (Internal = Layout A,
|
||||
one per job; External = Layout B, one per box).
|
||||
|
||||
Keeps the QWeb templates thin: all field resolution, the customer
|
||||
short-code (shop-floor "secrecy cover"), em-dash/smart-quote cleanup for
|
||||
the entech wkhtmltopdf font, and the length-tiered notes font size live
|
||||
here in Python.
|
||||
"""
|
||||
from odoo import models
|
||||
|
||||
|
||||
def _clean(text):
|
||||
"""Strip the glyphs entech's wkhtmltopdf font mojibakes."""
|
||||
if not text:
|
||||
return ''
|
||||
t = str(text)
|
||||
for a, b in ((u'\u2014', '-'), (u'\u2013', '-'), (u'‘', "'"),
|
||||
(u'’', "'"), (u'“', '"'), (u'”', '"'),
|
||||
(u'…', '...'),
|
||||
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
|
||||
# type for "375ºF", the real degree '°' (U+00B0), and the ring
|
||||
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
|
||||
# html_container path (no .article UTF-8 wrapper - and adding one
|
||||
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
|
||||
(u'º', ''), (u'°', ''), (u'˚', '')):
|
||||
t = t.replace(a, b)
|
||||
return t.strip()
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
def _fp_sticker_shortcode(self, partner):
|
||||
"""ABC Manufacturing Inc -> 'ABC-MANU'. First 3 of word[0] + first 4
|
||||
of word[1] (alnum-only), uppercase. Single word -> first 3."""
|
||||
name = (partner.name or '') if partner else ''
|
||||
words = [''.join(c for c in w if c.isalnum()) for w in name.split()]
|
||||
words = [w for w in words if w]
|
||||
if len(words) >= 2:
|
||||
return (words[0][:3] + '-' + words[1][:4]).upper()
|
||||
if words:
|
||||
return words[0][:3].upper()
|
||||
return name or '-'
|
||||
|
||||
def _fp_note_pt(self, text):
|
||||
"""Length-tiered notes font (pt) so long instructions stay on one
|
||||
label. Mirrors the approved mockups."""
|
||||
n = len(text or '')
|
||||
if n <= 180:
|
||||
return 11.0
|
||||
if n <= 320:
|
||||
return 10.0
|
||||
if n <= 520:
|
||||
return 9.0
|
||||
return 8.5
|
||||
|
||||
def _fp_sticker_data(self):
|
||||
"""Resolved display values for the job sticker (both variants)."""
|
||||
self.ensure_one()
|
||||
job = self
|
||||
line = job.sale_order_line_ids[:1] if 'sale_order_line_ids' in job._fields \
|
||||
else job.env['sale.order.line']
|
||||
part = (('part_catalog_id' in job._fields and job.part_catalog_id)
|
||||
or (line and 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id)
|
||||
or False)
|
||||
so = job.sale_order_id
|
||||
|
||||
rev = ''
|
||||
if part and getattr(part, 'revision', False):
|
||||
rev = (part.revision or '').strip()
|
||||
if rev.lower().startswith('rev '):
|
||||
rev = rev[4:].strip()
|
||||
|
||||
due = job.date_deadline or (so and so.commitment_date) or False
|
||||
due_s = due.strftime('%b %d %Y') if due else ''
|
||||
|
||||
thk = ''
|
||||
if line and 'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range:
|
||||
thk = _clean(line.x_fc_thickness_range)
|
||||
|
||||
q = job.qty or 0
|
||||
qty = int(q) if float(q).is_integer() else q
|
||||
|
||||
return {
|
||||
'wo': job.name or '',
|
||||
'part': ((part.part_number if part and getattr(part, 'part_number', False)
|
||||
else (part.name if part else '')) or ''),
|
||||
'rev': rev,
|
||||
'customer': self._fp_sticker_shortcode(job.partner_id),
|
||||
'customer_full': job.partner_id.name or '',
|
||||
'po': (so and getattr(so, 'x_fc_po_number', False)) or '',
|
||||
'qty': qty,
|
||||
'due': due_s,
|
||||
'thk': thk,
|
||||
# Real thickness present (has a digit) - drives the prominent
|
||||
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
|
||||
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
|
||||
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
|
||||
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
|
||||
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
|
||||
'customer_notes': _clean(line.name) if line else '',
|
||||
}
|
||||
|
||||
def _fp_sticker_boxes(self):
|
||||
"""The job's tracked boxes (External sticker prints one label each).
|
||||
Empty recordset when none yet - the template falls back to 1/1."""
|
||||
self.ensure_one()
|
||||
if self.sale_order_id and 'fp.box' in self.env:
|
||||
return self.env['fp.box'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='box_number')
|
||||
return self.env['fp.box'] if 'fp.box' in self.env else self.browse()
|
||||
@@ -1,315 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Sub 14 - Configurable workflow status bar.
|
||||
|
||||
Each job carries a workflow_state_id that auto-advances along a
|
||||
shop-configurable sequence of milestones (Draft → Confirmed → Received
|
||||
→ In Progress → Inspected → Shipped → Done - by default).
|
||||
|
||||
Recipe authors tag specific recipe steps as "this step's completion
|
||||
triggers workflow state X" via process_node.triggers_workflow_state_id.
|
||||
The default mapping is by step.default_kind (so out-of-the-box recipes
|
||||
just work), with per-recipe override on each operation node.
|
||||
|
||||
Why this lives in fusion_plating_jobs (not core):
|
||||
* It depends on fp.job.step which is implemented here
|
||||
* Recipe-side trigger fields are added via _inherit on
|
||||
fusion.plating.process.node (also here, in fp_job.py)
|
||||
|
||||
The catalog seed lives in data/fp_workflow_state_data.xml and ships
|
||||
the 7 default milestones. Settings UI lets shops add more.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpJobWorkflowState(models.Model):
|
||||
_name = 'fp.job.workflow.state'
|
||||
_description = 'Fusion Plating - Job Workflow State (status bar milestone)'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'sequence, id'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='State Name',
|
||||
required=True,
|
||||
translate=True,
|
||||
tracking=True,
|
||||
help='Operator-facing label shown in the job status bar '
|
||||
'(e.g. "Received", "Inspected", "Shipped").',
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Stable identifier - used by code/migrations to reference '
|
||||
'this state without depending on the (translatable) name. '
|
||||
'Lowercase snake_case (e.g. "received", "inspected").',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Position of this state on the bar (left to right). '
|
||||
'10-spacing convention so authors can insert new states '
|
||||
'between existing ones without renumbering.',
|
||||
)
|
||||
color = fields.Selection(
|
||||
[
|
||||
('grey', 'Grey'),
|
||||
('blue', 'Blue'),
|
||||
('cyan', 'Cyan'),
|
||||
('yellow', 'Yellow'),
|
||||
('orange', 'Orange'),
|
||||
('green', 'Green'),
|
||||
('success', 'Success Green'),
|
||||
('danger', 'Danger Red'),
|
||||
('purple', 'Purple'),
|
||||
],
|
||||
string='Color',
|
||||
default='grey',
|
||||
help='Status pill colour on the bar.',
|
||||
)
|
||||
is_initial = fields.Boolean(
|
||||
string='Initial State',
|
||||
default=False,
|
||||
help='Marks this as the starting state for new jobs. Only one '
|
||||
'state should be marked initial.',
|
||||
)
|
||||
is_terminal = fields.Boolean(
|
||||
string='Terminal State',
|
||||
default=False,
|
||||
help='Marks this as the final state. The bar stops advancing '
|
||||
'once a job reaches it. Only one state should be marked '
|
||||
'terminal.',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Internal notes on what this milestone represents and '
|
||||
'when it should fire. Not shown to operators.',
|
||||
)
|
||||
|
||||
# ---- Trigger conditions --------------------------------------------------
|
||||
#
|
||||
# A state is "passed" when ALL recipe steps matching its trigger
|
||||
# conditions are in done/skipped/cancelled. Two ways to define
|
||||
# which steps trigger:
|
||||
# 1. trigger_default_kinds - match on recipe step's default_kind
|
||||
# Selection. Easiest path - covers standard recipes that use
|
||||
# the curated kind values (receiving, final_inspect, ship, etc.)
|
||||
# 2. Per-recipe-node override via
|
||||
# fusion.plating.process.node.triggers_workflow_state_id
|
||||
# (defined in fp_job.py). Wins over default_kind matching.
|
||||
|
||||
trigger_default_kinds = fields.Char(
|
||||
string='Trigger Default Kinds',
|
||||
help='Comma-separated list of step.default_kind values. When the '
|
||||
'last recipe step matching any of these kinds is finished, '
|
||||
'the state passes. Example: "receiving,inspect" for a '
|
||||
'"Received" state. Leave blank if you only want to use '
|
||||
'per-recipe-node overrides.',
|
||||
)
|
||||
|
||||
trigger_first_step_started = fields.Boolean(
|
||||
string='Trigger on First Step Started',
|
||||
default=False,
|
||||
help='Special trigger - passes as soon as the first wet step '
|
||||
'(or any step with kind not in inspection/admin) starts. '
|
||||
'Used for the "In Progress" milestone.',
|
||||
)
|
||||
|
||||
trigger_all_steps_done = fields.Boolean(
|
||||
string='Trigger on All Steps Done',
|
||||
default=False,
|
||||
help='Special trigger - passes when every non-cancelled step '
|
||||
'is in done/skipped state. Used for the "Done" milestone.',
|
||||
)
|
||||
|
||||
trigger_on_delivery_state = fields.Boolean(
|
||||
string='Trigger on Delivery Delivered',
|
||||
default=False,
|
||||
help='Special trigger - passes once the fusion.plating.delivery '
|
||||
'linked to the job (job.delivery_id) reaches state="delivered". '
|
||||
'Used for the Shipped milestone in lieu of recipe-side '
|
||||
'default_kind="ship" tagging. Shipping is logistics, not '
|
||||
'manufacturing - keeping the trigger off the recipe lets us '
|
||||
'route deliveries (split shipments, RMA reverse-flow, '
|
||||
'customer pickup) independently from plating steps.',
|
||||
)
|
||||
|
||||
trigger_on_job_state = fields.Selection(
|
||||
[
|
||||
('confirmed', 'Job Confirmed'),
|
||||
('in_progress', 'Job In Progress'),
|
||||
('done', 'Job Done'),
|
||||
],
|
||||
string='Trigger on Job State',
|
||||
help='State-driven trigger: this milestone passes when '
|
||||
'fp.job.state reaches AT LEAST the chosen value. Fallback '
|
||||
'for jobs whose recipes do not tag steps with default_kind '
|
||||
'(so default_kind-driven triggers cannot fire). Order: '
|
||||
'draft < confirmed < in_progress/on_hold < done. '
|
||||
'Use for Confirmed ("confirmed") and optionally as a '
|
||||
'safety-net for Done ("done").',
|
||||
)
|
||||
|
||||
trigger_on_parts_received = fields.Boolean(
|
||||
string='Trigger on Parts Received',
|
||||
default=False,
|
||||
help='Special trigger - passes once the job\'s sale order has '
|
||||
'a receiving status of "partial" or "received" (set by '
|
||||
'the fp.receiving inbound logistics flow). Used by the '
|
||||
'Received milestone in lieu of recipe-side '
|
||||
'default_kind="receiving" tagging. Receiving is a pre-'
|
||||
'recipe activity (parts physically arrive on the dock) - '
|
||||
'keeping the trigger off recipe steps means shops without '
|
||||
'a "Receiving" step in their recipe still see the bar '
|
||||
'advance correctly.',
|
||||
)
|
||||
|
||||
block_when_quality_hold = fields.Boolean(
|
||||
string='Blocked by Quality Hold',
|
||||
default=False,
|
||||
help='If True, this state will NOT pass while there is an open '
|
||||
'quality hold on the job. Used for the "Inspected" '
|
||||
'milestone - you can finish the inspection step but the '
|
||||
'state stays at the previous milestone until the NCR clears.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_workflow_state_code_uniq', 'unique(code)',
|
||||
'Workflow state code must be unique.'),
|
||||
]
|
||||
|
||||
# NOTE: no display_name override on purpose. Earlier draft computed
|
||||
# "Name [code]" so admin pages could disambiguate at a glance, but
|
||||
# that string bled into the operator status bar (every pill rendered
|
||||
# as "Received [received]"). The admin list view shows code as its
|
||||
# own column, so we don't need it baked into display_name.
|
||||
|
||||
# ---- Trigger evaluation --------------------------------------------------
|
||||
|
||||
def _fp_kinds_set(self):
|
||||
"""Parse trigger_default_kinds into a set of kind strings."""
|
||||
self.ensure_one()
|
||||
if not self.trigger_default_kinds:
|
||||
return set()
|
||||
return {
|
||||
k.strip() for k in self.trigger_default_kinds.split(',')
|
||||
if k.strip()
|
||||
}
|
||||
|
||||
def _fp_is_passed_for_job(self, job):
|
||||
"""Return True if this state's trigger conditions are met by
|
||||
the given fp.job. Called from the job's compute method.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Initial state - always passed (every job starts here)
|
||||
if self.is_initial:
|
||||
return True
|
||||
|
||||
Step = self.env['fp.job.step']
|
||||
steps = job.step_ids
|
||||
|
||||
# Special trigger: all steps done
|
||||
if self.trigger_all_steps_done:
|
||||
non_cancelled = steps.filtered(lambda s: s.state != 'cancelled')
|
||||
if not non_cancelled:
|
||||
return False
|
||||
return all(s.state in ('done', 'skipped') for s in non_cancelled)
|
||||
|
||||
# Special trigger: linked delivery has been marked delivered
|
||||
if self.trigger_on_delivery_state:
|
||||
return bool(
|
||||
job.delivery_id and job.delivery_id.state == 'delivered'
|
||||
)
|
||||
|
||||
# Special trigger: parts physically received (pre-recipe).
|
||||
if self.trigger_on_parts_received:
|
||||
so = job.sale_order_id
|
||||
if (so and 'x_fc_receiving_status' in so._fields
|
||||
and so.x_fc_receiving_status
|
||||
in ('partial', 'received')):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Special trigger: job.state reached ("at least" semantics).
|
||||
if self.trigger_on_job_state:
|
||||
order = {
|
||||
'draft': 0,
|
||||
'confirmed': 1,
|
||||
'in_progress': 2,
|
||||
'on_hold': 2,
|
||||
'done': 3,
|
||||
}
|
||||
required = order.get(self.trigger_on_job_state, -1)
|
||||
actual = order.get(job.state, -1)
|
||||
return required >= 0 and actual >= required
|
||||
|
||||
# Special trigger: first wet step started.
|
||||
# For tagged recipes (any step has kind in wet/bake/mask/rack),
|
||||
# use strict kind-based check. For untagged recipes (all steps
|
||||
# are kind='other'), fall back to "any step started" so the
|
||||
# milestone fires regardless of recipe authoring quality.
|
||||
if self.trigger_first_step_started:
|
||||
wet_kinds = ('wet', 'bake', 'mask', 'rack')
|
||||
has_kind_tagging = any(s.kind in wet_kinds for s in steps)
|
||||
if has_kind_tagging:
|
||||
production_started = any(
|
||||
s.state in ('in_progress', 'paused', 'done')
|
||||
and (s.kind in wet_kinds)
|
||||
for s in steps
|
||||
)
|
||||
else:
|
||||
# Untagged recipe - any started step counts as
|
||||
# "production has started".
|
||||
production_started = any(
|
||||
s.state in ('in_progress', 'paused', 'done')
|
||||
for s in steps
|
||||
)
|
||||
return production_started
|
||||
|
||||
# Standard trigger: ALL recipe steps matching the trigger
|
||||
# (default_kind in our list OR per-node override pointing at
|
||||
# us) must be in a terminal state.
|
||||
kinds = self._fp_kinds_set()
|
||||
matching_steps = steps.filtered(
|
||||
lambda s: (
|
||||
# Per-node override wins
|
||||
(s.recipe_node_id
|
||||
and s.recipe_node_id.triggers_workflow_state_id
|
||||
and s.recipe_node_id.triggers_workflow_state_id.id == self.id)
|
||||
or
|
||||
# Default-kind match
|
||||
(kinds and s.recipe_node_id
|
||||
and s.recipe_node_id.default_kind in kinds)
|
||||
)
|
||||
)
|
||||
if not matching_steps:
|
||||
# Nothing matches - this state can't pass for this recipe.
|
||||
# Treat as not-passed so the bar stays at the previous state.
|
||||
return False
|
||||
|
||||
# Every matching step must be terminal
|
||||
if not all(
|
||||
s.state in ('done', 'skipped', 'cancelled')
|
||||
for s in matching_steps
|
||||
):
|
||||
return False
|
||||
|
||||
# Quality-hold gate (optional)
|
||||
if self.block_when_quality_hold:
|
||||
QH = self.env.get('fusion.plating.quality.hold')
|
||||
if QH is not None:
|
||||
open_holds = QH.search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', 'not in', ('closed', 'cancelled')),
|
||||
])
|
||||
if open_holds:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1,27 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Adds 'job_confirmed' and 'job_complete' trigger events to the
|
||||
# fp.notification.template selection. Fired from fp.job lifecycle
|
||||
# hooks (action_confirm, button_mark_done).
|
||||
#
|
||||
# bridge_mrp's existing 'mo_confirmed' / 'mo_complete' triggers
|
||||
# stay alive for the legacy MO flow.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpNotificationTemplate(models.Model):
|
||||
_inherit = 'fp.notification.template'
|
||||
|
||||
trigger_event = fields.Selection(
|
||||
selection_add=[
|
||||
('job_confirmed', 'Plating Job Confirmed'),
|
||||
('job_complete', 'Plating Job Complete'),
|
||||
],
|
||||
ondelete={
|
||||
'job_confirmed': 'cascade',
|
||||
'job_complete': 'cascade',
|
||||
},
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Add a back-reference from fusion.plating.portal.job to the native
|
||||
# fp.job. Coexists with any future x_fc_production_id (legacy
|
||||
# mrp.production link) added by bridge_mrp.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionPlatingPortalJob(models.Model):
|
||||
_inherit = 'fusion.plating.portal.job'
|
||||
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
string='Work Order',
|
||||
index=True,
|
||||
help='Native fp.job link. Coexists with x_fc_production_id (legacy '
|
||||
'mrp.production link).',
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 - parallel job/step links on fusion.plating.quality.hold.
|
||||
# Coexists with bridge_mrp's existing production_id link.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionPlatingQualityHold(models.Model):
|
||||
_inherit = 'fusion.plating.quality.hold'
|
||||
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
string='Work Order',
|
||||
index=True,
|
||||
help="Native fp.job link. Coexists with bridge_mrp's production_id "
|
||||
"link.",
|
||||
)
|
||||
x_fc_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='Plating Step',
|
||||
index=True,
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 / Phase 9 - native-job link on fp.racking.inspection.
|
||||
# Coexists with the legacy production_id (mrp.production) link; either
|
||||
# (or both) may be set.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FpRackingInspection(models.Model):
|
||||
_inherit = 'fp.racking.inspection'
|
||||
|
||||
# x_fc_job_id is declared in the base receiving module so its views
|
||||
# can reference it. We add help/depends here.
|
||||
|
||||
@api.depends('x_fc_job_id.name', 'partner_id.name')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
if rec.x_fc_job_id:
|
||||
rec.name = _('Inspection - %s') % rec.x_fc_job_id.name
|
||||
else:
|
||||
rec.name = _('Racking Inspection')
|
||||
|
||||
@api.depends('x_fc_job_id.sale_order_id')
|
||||
def _compute_sale_order(self):
|
||||
for rec in self:
|
||||
so = (rec.x_fc_job_id.sale_order_id
|
||||
if rec.x_fc_job_id and rec.x_fc_job_id.sale_order_id
|
||||
else False)
|
||||
rec.sale_order_id = so or False
|
||||
rec.partner_id = so.partner_id if so else False
|
||||
|
||||
@api.constrains('x_fc_job_id')
|
||||
def _check_link_present(self):
|
||||
for rec in self:
|
||||
if not rec.x_fc_job_id:
|
||||
raise ValidationError(_(
|
||||
'Racking inspection must reference a plating job.'
|
||||
))
|
||||
|
||||
@api.constrains('x_fc_job_id')
|
||||
def _check_job_unique(self):
|
||||
for rec in self:
|
||||
if not rec.x_fc_job_id:
|
||||
continue
|
||||
dup = self.search_count([
|
||||
('x_fc_job_id', '=', rec.x_fc_job_id.id),
|
||||
('id', '!=', rec.id),
|
||||
])
|
||||
if dup:
|
||||
raise ValidationError(_(
|
||||
'Only one racking inspection per plating job.'
|
||||
))
|
||||
@@ -1,99 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Adds the Work Order smart button + header action to fp.receiving so
|
||||
# the receiving form mirrors the SO's WO entry point. Button appears
|
||||
# once the receiving is closed and stays until every linked fp.job
|
||||
# reaches state='done'.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
x_fc_fp_job_count = fields.Integer(
|
||||
string='Work Orders',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_show_work_order_btn = fields.Boolean(
|
||||
string='Show Work Order Button',
|
||||
compute='_compute_show_work_order_btn',
|
||||
help='True once this receiving is closed and at least one linked '
|
||||
'work order is still open (state != done). Hidden again '
|
||||
'when every job is done.',
|
||||
)
|
||||
|
||||
def _compute_fp_job_count(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for rec in self:
|
||||
if rec.sale_order_id:
|
||||
rec.x_fc_fp_job_count = Job.search_count(
|
||||
[('sale_order_id', '=', rec.sale_order_id.id)]
|
||||
)
|
||||
else:
|
||||
rec.x_fc_fp_job_count = 0
|
||||
|
||||
def _compute_show_work_order_btn(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for rec in self:
|
||||
if rec.state != 'closed' or not rec.sale_order_id:
|
||||
rec.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
|
||||
rec.x_fc_show_work_order_btn = bool(jobs) and any(
|
||||
j.state != 'done' for j in jobs
|
||||
)
|
||||
|
||||
def action_view_fp_jobs(self):
|
||||
"""Open the work order(s) linked to this receiving's sale order."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return False
|
||||
jobs = self.env['fp.job'].search([
|
||||
('sale_order_id', '=', self.sale_order_id.id),
|
||||
])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Work Orders'),
|
||||
'res_model': 'fp.job',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.sale_order_id.id)],
|
||||
'context': {'default_sale_order_id': self.sale_order_id.id},
|
||||
}
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
|
||||
# ---- Sticker printing from the Receiving screen (2026-06-04) ----------
|
||||
# Both stickers loop the SO's boxes (one label per box). Pass a SINGLE
|
||||
# work order: the box loop is sale-order-scoped, so feeding every job
|
||||
# would reprint each box label once per job. One job → exactly one label
|
||||
# per box. Falls back to a single 1/1 label when no boxes exist yet.
|
||||
def _fp_sticker_jobs(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return self.env['fp.job']
|
||||
return self.env['fp.job'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='id', limit=1)
|
||||
|
||||
def _fp_print_sticker(self, xmlid):
|
||||
self.ensure_one()
|
||||
jobs = self._fp_sticker_jobs()
|
||||
if not jobs:
|
||||
raise UserError(_(
|
||||
'No work order exists for this receiving yet - create the '
|
||||
'Work Order before printing stickers.'))
|
||||
return self.env.ref(xmlid).report_action(jobs)
|
||||
|
||||
def action_print_external_sticker(self):
|
||||
"""Customer (external) box sticker(s) for this receiving's WO."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker')
|
||||
|
||||
def action_print_internal_sticker(self):
|
||||
"""Shop (internal) box sticker(s) - same layout, internal notes."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker_internal')
|
||||
@@ -1,22 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 - parallel job/step links on fp.thickness.reading.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpThicknessReading(models.Model):
|
||||
_inherit = 'fp.thickness.reading'
|
||||
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
string='Work Order',
|
||||
index=True,
|
||||
)
|
||||
x_fc_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='Plating Step',
|
||||
index=True,
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Tags KPI values by source: 'mrp' (legacy bridge_mrp rollups) vs
|
||||
# 'jobs' (native fp.job rollups). Lets Phase 9 / Phase 10 dashboards
|
||||
# show both side-by-side or filter to one.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionPlatingKpiValue(models.Model):
|
||||
_inherit = 'fusion.plating.kpi.value'
|
||||
|
||||
x_fc_source = fields.Selection(
|
||||
[
|
||||
('mrp', 'MRP (legacy)'),
|
||||
('jobs', 'Native Jobs'),
|
||||
],
|
||||
string='Data Source',
|
||||
default='mrp',
|
||||
index=True,
|
||||
help='Which data path produced this KPI value. Phase 9+ '
|
||||
'rollups from fp.job/fp.job.step set this to jobs.',
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Native fp.job margin report - replaces report_wo_margin which binds
|
||||
# to mrp.production. Uses fp.job.step.cost_total (already computed in
|
||||
# Phase 1: duration_actual / 60 * cost_per_hour).
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ReportFpJobMargin(models.AbstractModel):
|
||||
# Odoo looks up the report's data model via report.<report_name>.
|
||||
# The action's report_name is `fusion_plating_jobs.report_fp_job_margin_template`,
|
||||
# so this MUST be `report.fusion_plating_jobs.report_fp_job_margin_template`.
|
||||
# Pre-2026-05-12 the model name was missing the `_template` suffix,
|
||||
# which silently caused _get_report_values to never fire and the
|
||||
# template rendered with no `rows` -> blank PDF. The t-field error
|
||||
# was masking this because it crashed earlier; once t-field was
|
||||
# swapped to t-esc the blank-render surfaced.
|
||||
_name = 'report.fusion_plating_jobs.report_fp_job_margin_template'
|
||||
_description = 'Work Order Margin Report'
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
Job = self.env['fp.job']
|
||||
jobs = Job.browse(docids)
|
||||
rows = []
|
||||
for job in jobs:
|
||||
step_rows = []
|
||||
total_labour = 0.0
|
||||
total_minutes = 0.0
|
||||
for step in job.step_ids.sorted('sequence'):
|
||||
step_rows.append({
|
||||
'sequence': step.sequence,
|
||||
'name': step.name,
|
||||
'work_centre': step.work_centre_id.name if step.work_centre_id else '-',
|
||||
'duration_expected': step.duration_expected,
|
||||
'duration_actual': step.duration_actual,
|
||||
'rate': step.cost_per_hour,
|
||||
'cost': step.cost_total,
|
||||
})
|
||||
total_labour += step.cost_total
|
||||
total_minutes += step.duration_actual
|
||||
rows.append({
|
||||
'job': job,
|
||||
'steps': step_rows,
|
||||
'total_minutes': total_minutes,
|
||||
'total_labour': total_labour,
|
||||
'quoted_revenue': job.quoted_revenue,
|
||||
'actual_cost': job.actual_cost,
|
||||
'margin': job.margin,
|
||||
'margin_pct': job.margin_pct,
|
||||
})
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'fp.job',
|
||||
'docs': jobs,
|
||||
'rows': rows,
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
x_fc_initials = fields.Char(
|
||||
string='Plating Initials',
|
||||
help='Operator / inspector initials used to pre-fill signature '
|
||||
'and "Reviewer Initials" style prompts in the Record Inputs '
|
||||
'dialog. Editable in the dialog itself - when the user types '
|
||||
'a different value and saves, it persists here for every '
|
||||
'future job and step.',
|
||||
)
|
||||
x_fc_signature_image = fields.Binary(
|
||||
string='Plating Signature',
|
||||
attachment=True,
|
||||
help='Drawn or uploaded signature image. Used in WO detail and '
|
||||
'certificate reports for any signature-type prompt this user '
|
||||
'signed off on; falls back to typed initials when blank. '
|
||||
'Capture it once in user preferences; it stamps every '
|
||||
'future sign-off automatically.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _fp_default_initials(self):
|
||||
"""Best-effort initials derived from the user's display name.
|
||||
|
||||
Used as a fallback when ``x_fc_initials`` is empty so the
|
||||
operator still gets a sensible pre-fill on their first run.
|
||||
E.g. "John Doe" -> "JD", "Mary Anne Smith" -> "MAS".
|
||||
"""
|
||||
name = (self.name or '').strip()
|
||||
if not name:
|
||||
return ''
|
||||
return ''.join(
|
||||
piece[0] for piece in name.split() if piece
|
||||
).upper()[:6]
|
||||
|
||||
def fp_get_initials(self):
|
||||
"""Resolve the user's initials for the dialog: stored override
|
||||
first, fall back to the auto-derived value from their name."""
|
||||
self.ensure_one()
|
||||
return self.x_fc_initials or self._fp_default_initials()
|
||||
@@ -1,692 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# sale.order.action_confirm hook - creates fp.job records on confirm.
|
||||
# Sub 11 (2026-04-26) removed MRP entirely; fp.job is the only fulfilment
|
||||
# path. The former x_fc_use_native_jobs migration toggle was dropped in
|
||||
# 19.0.8.19.0 once the legacy bridge_mrp fallback became unreachable.
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
x_fc_fp_job_count = fields.Integer(
|
||||
string='Work Orders',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_show_work_order_btn = fields.Boolean(
|
||||
string='Show Work Order Header Button',
|
||||
compute='_compute_show_work_order_btn',
|
||||
help='True once any receiving record on this SO has closed and '
|
||||
'at least one work order is still open (state != done). '
|
||||
'Hidden again when every WO is done.',
|
||||
)
|
||||
x_fc_fp_certificate_count = fields.Integer(
|
||||
string='Certificates',
|
||||
compute='_compute_fp_certificate_count',
|
||||
help='Number of fp.certificate records issued (or draft) against '
|
||||
'this sale order. Surfaced as a smart button so Sarah/Tom '
|
||||
'can jump straight from the SO to the cert without having '
|
||||
'to drill through the linked Plating Job first.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-number hierarchy (2026-05-12 design)
|
||||
# See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_parent_number = fields.Integer(
|
||||
string='Parent Number',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
help='Set on confirm. Drives every linked document\'s name '
|
||||
'(WO-NNN, IN-NNN, CoC-NNN, ...). Immutable post-assignment.',
|
||||
)
|
||||
x_fc_quote_ref = fields.Char(
|
||||
string='Originally Quoted As',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='The quote-stage name (e.g. Q202605-200). Preserved when '
|
||||
'the SO is renamed on confirm.',
|
||||
)
|
||||
# Per-model counters - monotonic, never decrement. Source of truth
|
||||
# for the next sibling's x_fc_doc_index. Updated via row-locked SQL
|
||||
# in fp.parent.numbered.mixin so concurrent creates can't drift.
|
||||
#
|
||||
# Naming: `x_fc_pn_*_count` - the `pn_` infix distinguishes our
|
||||
# storage counters from pre-existing compute fields (e.g. the
|
||||
# `x_fc_delivery_count` compute in bridge_mrp, `x_fc_ncr_count`
|
||||
# in configurator, `x_fc_receiving_count` in fp_receiving) which
|
||||
# are surface counters for smart buttons. Distinct names avoid
|
||||
# the silent compute-override that made Tasks 3+9 fail until 9.5.
|
||||
x_fc_pn_wo_count = fields.Integer(string='Parent: WO Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_invoice_count = fields.Integer(string='Parent: Invoice Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_cn_count = fields.Integer(string='Parent: Credit Note Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_cert_count = fields.Integer(string='Parent: Certificate Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_delivery_count = fields.Integer(string='Parent: Delivery Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_receiving_count = fields.Integer(string='Parent: Receiving Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_pickup_count = fields.Integer(string='Parent: Pickup Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_ncr_count = fields.Integer(string='Parent: NCR Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_capa_count = fields.Integer(string='Parent: CAPA Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_hold_count = fields.Integer(string='Parent: Hold Count', readonly=True, copy=False, default=0)
|
||||
x_fc_pn_rma_count = fields.Integer(string='Parent: RMA Count', readonly=True, copy=False, default=0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 4 (Sub 11) - workflow-stage field + assigned-manager field
|
||||
# relocated from fusion_plating_bridge_mrp. Field re-declared with
|
||||
# the same selection + compute pointer; jobs is now the source of
|
||||
# truth so Phase 5 can delete bridge_mrp without losing the field.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_workflow_stage = fields.Selection(
|
||||
[
|
||||
('draft', 'Quote'),
|
||||
('awaiting_parts', 'Parts'),
|
||||
('inspecting', 'Inspecting'),
|
||||
('accept_parts', 'Accept'),
|
||||
('assign_work', 'Assign'),
|
||||
('in_production', 'Production'),
|
||||
('ready_to_ship', 'Ready'),
|
||||
('shipped', 'Shipped'),
|
||||
('invoicing', 'Invoicing'),
|
||||
('paid', 'Paid'),
|
||||
('complete', 'Done'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
compute='_compute_workflow_stage',
|
||||
string='Workflow Stage',
|
||||
help='Current position in the SO → Ship → Invoice workflow. '
|
||||
'Drives which next-step button is shown on the SO header.',
|
||||
)
|
||||
x_fc_assigned_manager_id = fields.Many2one(
|
||||
'res.users', string='Assigned Manager',
|
||||
help='The manager responsible for this job. Set when the job '
|
||||
'is confirmed (falls back to the salesperson).',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
def _compute_fp_job_count(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for so in self:
|
||||
so.x_fc_fp_job_count = Job.search_count(
|
||||
[('sale_order_id', '=', so.id)]
|
||||
)
|
||||
|
||||
def _compute_show_work_order_btn(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
for so in self:
|
||||
if Recv is None:
|
||||
so.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
has_closed_recv = bool(Recv.sudo().search_count([
|
||||
('sale_order_id', '=', so.id),
|
||||
('state', '=', 'closed'),
|
||||
]))
|
||||
if not has_closed_recv:
|
||||
so.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
jobs = Job.search([('sale_order_id', '=', so.id)])
|
||||
so.x_fc_show_work_order_btn = bool(jobs) and any(
|
||||
j.state != 'done' for j in jobs
|
||||
)
|
||||
|
||||
def _compute_fp_certificate_count(self):
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
for so in self:
|
||||
so.x_fc_fp_certificate_count = Cert.search_count(
|
||||
[('sale_order_id', '=', so.id)]
|
||||
)
|
||||
|
||||
def _compute_workflow_stage(self):
|
||||
"""Walk fp.job state to derive the SO workflow banner."""
|
||||
Job = self.env['fp.job']
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
for so in self:
|
||||
if so.state == 'cancel':
|
||||
so.x_fc_workflow_stage = 'cancelled'
|
||||
continue
|
||||
if so.state in ('draft', 'sent'):
|
||||
so.x_fc_workflow_stage = 'draft'
|
||||
continue
|
||||
|
||||
jobs = Job.search([('sale_order_id', '=', so.id)])
|
||||
all_jobs_done = bool(jobs) and all(
|
||||
j.state == 'done' for j in jobs
|
||||
)
|
||||
|
||||
shipped = False
|
||||
if Delivery is not None and jobs:
|
||||
if 'x_fc_job_id' in Delivery._fields:
|
||||
shipped = bool(Delivery.search_count([
|
||||
('x_fc_job_id', 'in', jobs.ids),
|
||||
('state', '=', 'delivered'),
|
||||
]))
|
||||
|
||||
posted_invoices = so.invoice_ids.filtered(
|
||||
lambda i: i.state == 'posted'
|
||||
)
|
||||
has_posted_invoice = bool(posted_invoices)
|
||||
all_paid = has_posted_invoice and all(
|
||||
i.payment_state in ('paid', 'in_payment')
|
||||
for i in posted_invoices
|
||||
)
|
||||
|
||||
if shipped and all_paid:
|
||||
so.x_fc_workflow_stage = 'complete'
|
||||
continue
|
||||
if all_paid and not shipped:
|
||||
so.x_fc_workflow_stage = 'paid'
|
||||
continue
|
||||
if shipped and has_posted_invoice:
|
||||
so.x_fc_workflow_stage = 'invoicing'
|
||||
continue
|
||||
if shipped:
|
||||
so.x_fc_workflow_stage = 'shipped'
|
||||
continue
|
||||
if all_jobs_done:
|
||||
so.x_fc_workflow_stage = 'ready_to_ship'
|
||||
continue
|
||||
|
||||
recv_status = so.x_fc_receiving_status or 'not_received'
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
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
|
||||
so.x_fc_workflow_stage = 'in_production'
|
||||
continue
|
||||
so.x_fc_workflow_stage = (
|
||||
'in_production' if jobs else 'awaiting_parts'
|
||||
)
|
||||
|
||||
def action_view_fp_jobs(self):
|
||||
self.ensure_one()
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', self.id)])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Plating Jobs'),
|
||||
'res_model': 'fp.job',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
if len(jobs) == 1:
|
||||
action.update({
|
||||
'view_mode': 'form',
|
||||
'res_id': jobs.id,
|
||||
})
|
||||
return action
|
||||
|
||||
def action_view_fp_certificates(self):
|
||||
"""Smart-button target - open the certificate(s) linked to this
|
||||
SO. One cert → form view; many → list view filtered to this SO."""
|
||||
self.ensure_one()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('sale_order_id', '=', self.id),
|
||||
])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Certificates'),
|
||||
'res_model': 'fp.certificate',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {
|
||||
'default_sale_order_id': self.id,
|
||||
'default_partner_id': self.partner_id.id,
|
||||
},
|
||||
}
|
||||
if len(certs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': certs.id})
|
||||
return action
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-number hierarchy - quote naming on create
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Draw Q-YYYYMM-N from fp.quote.number when no explicit name.
|
||||
|
||||
The drawn name is also stashed in x_fc_quote_ref so it survives
|
||||
the confirm-time rename to SO-<parent_number>. If the caller
|
||||
passed an explicit name we preserve that AND mirror it into
|
||||
x_fc_quote_ref (covers data migration, restore, etc.).
|
||||
"""
|
||||
Seq = self.env['ir.sequence']
|
||||
for vals in vals_list:
|
||||
existing = vals.get('name')
|
||||
if not existing or existing == _('New') or existing == 'New':
|
||||
quote_name = Seq.next_by_code('fp.quote.number')
|
||||
if quote_name:
|
||||
vals['name'] = quote_name
|
||||
vals.setdefault('x_fc_quote_ref', quote_name)
|
||||
elif not vals.get('x_fc_quote_ref'):
|
||||
vals['x_fc_quote_ref'] = existing
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Assign parent number + rename Q-…-N to SO-<parent>, then run
|
||||
the standard confirm (which kicks off WO creation).
|
||||
|
||||
Parent number is drawn from fp.parent.number; the quote name
|
||||
was already saved to x_fc_quote_ref on create() so it survives
|
||||
the rename. Idempotent - if x_fc_parent_number is already set,
|
||||
the rename is skipped (re-confirm scenarios)."""
|
||||
Seq = self.env['ir.sequence']
|
||||
for so in self:
|
||||
if so.x_fc_parent_number:
|
||||
continue
|
||||
parent = Seq.next_by_code('fp.parent.number')
|
||||
if not parent:
|
||||
raise UserError(_(
|
||||
'Sequence fp.parent.number is missing. Reinstall '
|
||||
'fusion_plating to restore it.'
|
||||
))
|
||||
parent_int = int(parent)
|
||||
old_name = so.name
|
||||
# fp_allow_name_rename whitelists this single legitimate
|
||||
# rename path through the immutability write() guard
|
||||
# (added in Task 11).
|
||||
so.with_context(fp_allow_name_rename=True).write({
|
||||
'name': f'SO-{parent_int}',
|
||||
'x_fc_parent_number': parent_int,
|
||||
})
|
||||
so.message_post(body=Markup(_(
|
||||
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
|
||||
)) % (old_name, so.name))
|
||||
result = super().action_confirm()
|
||||
for so in self:
|
||||
so._fp_auto_create_job()
|
||||
# Auto-confirm any draft jobs we just created so steps
|
||||
# generate immediately (no manager click required).
|
||||
# Best-effort: an exception in side-effects shouldn't
|
||||
# block the SO confirm itself.
|
||||
draft_jobs = self.env['fp.job'].sudo().search([
|
||||
('sale_order_id', '=', so.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
for job in draft_jobs:
|
||||
try:
|
||||
job.action_confirm()
|
||||
except Exception as exc:
|
||||
so.message_post(body=_(
|
||||
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
|
||||
'Confirm manually from the job form.'
|
||||
) % {'job': job.name, 'err': exc})
|
||||
return result
|
||||
|
||||
def _create_invoices(self, grouped=False, final=False, date=None):
|
||||
"""Set fp_from_so_invoice=True so account.move.create() allows
|
||||
the customer-invoice creation (the direct-creation block is
|
||||
bypassed via this context flag). Also lets the parent-numbered
|
||||
mixin find the originating SO without depending on invoice_origin.
|
||||
"""
|
||||
return super(SaleOrder, self.with_context(
|
||||
fp_from_so_invoice=True,
|
||||
fp_invoice_source_so_id=self.id if len(self) == 1 else False,
|
||||
))._create_invoices(grouped=grouped, final=final, date=date)
|
||||
|
||||
def unlink(self):
|
||||
"""Spec §6.2 - confirmed SOs are part of the compliance audit
|
||||
trail and cannot be deleted. Cancellation must go through the
|
||||
state machine instead. Draft SOs (no parent_number assigned
|
||||
yet) remain freely deletable per Odoo standard. Applies to
|
||||
all users including administrators."""
|
||||
for so in self:
|
||||
if so.x_fc_parent_number:
|
||||
raise UserError(_(
|
||||
'Sale Order "%(name)s" cannot be deleted - it has '
|
||||
'been confirmed (parent number %(parent)s issued) '
|
||||
'and is part of the compliance audit trail. Cancel '
|
||||
'it instead. This rule applies to all users '
|
||||
'including administrators.'
|
||||
) % {'name': so.display_name, 'parent': so.x_fc_parent_number})
|
||||
return super().unlink()
|
||||
|
||||
def _fp_resolve_recipe_for_line(self, line):
|
||||
"""Recipe resolution with Express-Orders SO header fallback.
|
||||
|
||||
Priority (most-specific first):
|
||||
1. line.x_fc_process_variant_id - explicit per-line variant
|
||||
(always wins; this is where G3 propagation lands a value).
|
||||
2. self.x_fc_material_process - Express Orders order-level
|
||||
recipe. Catches the case where G3 propagation failed to
|
||||
reach the line but the header has the recipe.
|
||||
3. part.default_process_id - part's flagged default
|
||||
variant. Customer-and-part-tuned recipe.
|
||||
4. part.recipe_id - legacy fallback.
|
||||
Returns the recipe record or an empty recordset.
|
||||
"""
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
part = (
|
||||
'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id
|
||||
) or False
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
picked = (
|
||||
'x_fc_process_variant_id' in line._fields
|
||||
and line.x_fc_process_variant_id
|
||||
) or False
|
||||
if picked:
|
||||
return picked
|
||||
# Express Orders header recipe (2026-05-27 fallback)
|
||||
if 'x_fc_material_process' in self._fields and self.x_fc_material_process:
|
||||
return self.x_fc_material_process
|
||||
if part and 'default_process_id' in part._fields and part.default_process_id:
|
||||
return part.default_process_id
|
||||
if part and 'recipe_id' in part._fields and part.recipe_id:
|
||||
return part.recipe_id
|
||||
return Node
|
||||
|
||||
def _fp_auto_create_job(self):
|
||||
"""Create fp.job(s) from the SO's plating lines.
|
||||
|
||||
2026-05-12 parent-number rewrite: lines are grouped by resolved
|
||||
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
|
||||
WO-<parent> (bare). If N>1 groups → N WOs named WO-<parent>-01,
|
||||
WO-<parent>-02, ..., ordered by min line sequence so suffixes
|
||||
mirror SO display order. WO names are then immutable; later
|
||||
manual additions to the SO get the next index via the mixin.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Job = self.env['fp.job'].sudo()
|
||||
|
||||
# Idempotency: skip if a job already references this SO
|
||||
existing = Job.search([('sale_order_id', '=', self.id)], limit=1)
|
||||
if existing:
|
||||
return
|
||||
|
||||
# Find plating lines (those with a part_catalog_id or
|
||||
# customer_spec_id).
|
||||
plating_lines = self.order_line.filtered(
|
||||
lambda l: (
|
||||
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
|
||||
or ('x_fc_customer_spec_id' in l._fields and l.x_fc_customer_spec_id)
|
||||
)
|
||||
)
|
||||
# Fallback: SOs that carry part on the header but not on the
|
||||
# line. Treat the entire order as one plating job so the planner
|
||||
# gets an fp.job to work against.
|
||||
if not plating_lines and self.order_line and (
|
||||
'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
|
||||
):
|
||||
_logger.info(
|
||||
'SO %s: no line-level part but header carries one - '
|
||||
'treating all lines as a single plating job.', self.name,
|
||||
)
|
||||
plating_lines = self.order_line
|
||||
if not plating_lines:
|
||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||
return
|
||||
|
||||
# Group by (recipe, part, spec, thickness, serial). Lines that
|
||||
# share ALL FIVE collapse into one WO. Bundling lines with
|
||||
# different specs / thicknesses / serials under one WO would
|
||||
# carry the first line's values onto the cert + sticker -
|
||||
# silent mis-attestation. No-recipe lines still get their own
|
||||
# group each.
|
||||
groups = {}
|
||||
unrecipe_idx = 0
|
||||
for line in plating_lines:
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
part_id = (
|
||||
'x_fc_part_catalog_id' in line._fields
|
||||
and line.x_fc_part_catalog_id.id
|
||||
) or False
|
||||
spec_id = (
|
||||
'x_fc_customer_spec_id' in line._fields
|
||||
and line.x_fc_customer_spec_id.id
|
||||
) or False
|
||||
thickness_key = (
|
||||
'x_fc_thickness_range' in line._fields
|
||||
and (line.x_fc_thickness_range or '').strip()
|
||||
) or False
|
||||
serial_id = (
|
||||
'x_fc_serial_id' in line._fields
|
||||
and line.x_fc_serial_id.id
|
||||
) or False
|
||||
if recipe:
|
||||
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
|
||||
else:
|
||||
unrecipe_idx += 1
|
||||
key = ('no_recipe', unrecipe_idx)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
|
||||
# Order groups by min line sequence so dash-suffixes mirror SO
|
||||
# display order. Deterministic regardless of dict iteration order.
|
||||
ordered_keys = sorted(
|
||||
groups.keys(),
|
||||
key=lambda k: min(groups[k].mapped('sequence') or [0]),
|
||||
)
|
||||
n_groups = len(ordered_keys)
|
||||
parent = self.x_fc_parent_number # set by action_confirm earlier
|
||||
|
||||
# Create a job per group
|
||||
for idx, key in enumerate(ordered_keys, start=1):
|
||||
lines = groups[key]
|
||||
first_line = lines[0]
|
||||
qty = sum(lines.mapped('product_uom_qty'))
|
||||
part = (
|
||||
'x_fc_part_catalog_id' in first_line._fields
|
||||
and first_line.x_fc_part_catalog_id
|
||||
or False
|
||||
)
|
||||
customer_spec = (
|
||||
'x_fc_customer_spec_id' in first_line._fields
|
||||
and first_line.x_fc_customer_spec_id
|
||||
or False
|
||||
)
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
recipe = self._fp_resolve_recipe_for_line(first_line)
|
||||
|
||||
vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
'product_id': first_line.product_id.id if first_line.product_id else False,
|
||||
'qty': qty,
|
||||
'origin': self.name,
|
||||
'sale_order_id': self.id,
|
||||
'sale_order_line_ids': [(6, 0, lines.ids)],
|
||||
'date_deadline': self.commitment_date or self.date_order,
|
||||
}
|
||||
if part:
|
||||
vals['part_catalog_id'] = part.id
|
||||
if customer_spec:
|
||||
vals['customer_spec_id'] = customer_spec.id
|
||||
if recipe:
|
||||
vals['recipe_id'] = recipe.id
|
||||
|
||||
# Customer references - mirror onto the job so the shop floor
|
||||
# has them without round-tripping to the SO.
|
||||
if 'x_fc_customer_job_number' in self._fields \
|
||||
and self.x_fc_customer_job_number:
|
||||
vals['x_fc_customer_job_number'] = self.x_fc_customer_job_number
|
||||
if 'x_fc_po_number' in self._fields and self.x_fc_po_number:
|
||||
vals['x_fc_po_number'] = self.x_fc_po_number
|
||||
if 'x_fc_rush_order' in self._fields:
|
||||
vals['x_fc_rush_order'] = bool(self.x_fc_rush_order)
|
||||
|
||||
# Scheduling targets - mirror the SO's customer-facing dates.
|
||||
if 'x_fc_internal_deadline' in self._fields \
|
||||
and self.x_fc_internal_deadline:
|
||||
vals['x_fc_internal_deadline'] = self.x_fc_internal_deadline
|
||||
if 'x_fc_planned_start_date' in self._fields \
|
||||
and self.x_fc_planned_start_date:
|
||||
vals['x_fc_planned_start_date'] = self.x_fc_planned_start_date
|
||||
|
||||
# Operational notes - mirror so the shop has them on the WO.
|
||||
if 'x_fc_internal_note' in self._fields \
|
||||
and self.x_fc_internal_note:
|
||||
vals['x_fc_internal_note'] = self.x_fc_internal_note
|
||||
if 'x_fc_external_note' in self._fields \
|
||||
and self.x_fc_external_note:
|
||||
vals['x_fc_external_note'] = self.x_fc_external_note
|
||||
|
||||
# Customer spec / facility / manager - copy from SO if present
|
||||
if 'x_fc_customer_spec_id' in self._fields and self.x_fc_customer_spec_id:
|
||||
vals['customer_spec_id'] = self.x_fc_customer_spec_id.id
|
||||
if 'x_fc_facility_id' in self._fields and self.x_fc_facility_id:
|
||||
vals['facility_id'] = self.x_fc_facility_id.id
|
||||
if 'x_fc_manager_id' in self._fields and self.x_fc_manager_id:
|
||||
vals['manager_id'] = self.x_fc_manager_id.id
|
||||
|
||||
# Quoted revenue: sum line totals
|
||||
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
|
||||
|
||||
# Parent-number naming (2026-05-12). Bare for the single-group
|
||||
# case; zero-padded -NN suffix when multiple recipes split the
|
||||
# SO into multiple WOs. Set explicitly so fp.job.create() skips
|
||||
# its own naming fallback.
|
||||
if parent:
|
||||
if n_groups == 1:
|
||||
vals['name'] = f'WO-{parent}'
|
||||
vals['x_fc_doc_index'] = 1
|
||||
else:
|
||||
vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}'
|
||||
vals['x_fc_doc_index'] = idx
|
||||
|
||||
job = Job.create(vals)
|
||||
_logger.info(
|
||||
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
|
||||
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
||||
)
|
||||
|
||||
# Express Orders (2026-05-26) - apply per-line masking + bake
|
||||
# overrides to the new job. This runs BEFORE step generation
|
||||
# (which happens in fp.job.action_confirm) so the override rows
|
||||
# are in place when _generate_steps_from_recipe reads override_map.
|
||||
# Step.instructions writes are deferred to a second pass after
|
||||
# step gen - see fp.job.action_confirm override.
|
||||
if job.recipe_id and 'x_fc_masking_enabled' in self.env['sale.order.line']._fields:
|
||||
for sol in lines:
|
||||
if hasattr(sol, '_fp_apply_express_overrides_to_job'):
|
||||
sol._fp_apply_express_overrides_to_job(job)
|
||||
|
||||
# Bump SO counter to reflect the bulk creation. Future manual
|
||||
# WO additions pick up from here via the mixin standard path.
|
||||
if parent and n_groups:
|
||||
self.env.cr.execute(
|
||||
"UPDATE sale_order SET x_fc_pn_wo_count = %s WHERE id = %s",
|
||||
(n_groups, self.id),
|
||||
)
|
||||
self.invalidate_recordset(['x_fc_pn_wo_count'])
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 4 (Sub 11) - workflow stage action buttons.
|
||||
# Native versions of bridge_mrp's action_fp_* methods. Drop the
|
||||
# mrp.production lookups; talk to fp.job and fp.receiving directly.
|
||||
# ------------------------------------------------------------------
|
||||
def action_fp_mark_inspected(self):
|
||||
"""Flip open receivings from draft → inspecting."""
|
||||
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 == 'draft':
|
||||
rec.state = 'inspecting'
|
||||
self.message_post(body=_('Parts marked as inspecting.'))
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""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)]):
|
||||
# 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 = 'received'
|
||||
self.message_post(body=_('Parts accepted - ready to assign manager.'))
|
||||
return True
|
||||
|
||||
def action_fp_assign_to_me(self):
|
||||
"""Manager claims the SO and confirms its draft fp.jobs."""
|
||||
self.ensure_one()
|
||||
user = self.env.user
|
||||
self.x_fc_assigned_manager_id = user.id
|
||||
Job = self.env['fp.job']
|
||||
jobs = Job.search([
|
||||
('sale_order_id', '=', self.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
for job in jobs:
|
||||
try:
|
||||
job.action_confirm()
|
||||
except Exception as exc:
|
||||
self.message_post(body=_(
|
||||
'Auto-confirm of fp.job %s failed: %s'
|
||||
) % (job.name, exc))
|
||||
if 'manager_id' in job._fields and not job.manager_id:
|
||||
job.manager_id = user.id
|
||||
self.message_post(
|
||||
body=Markup(_(
|
||||
'Job assigned to <b>%s</b>. %d plating job(s) released to the floor.'
|
||||
)) % (user.name, len(jobs)),
|
||||
)
|
||||
return True
|
||||
|
||||
def action_fp_mark_shipped(self):
|
||||
"""Mark linked deliveries delivered (triggers auto-invoice)."""
|
||||
self.ensure_one()
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
if Delivery is None:
|
||||
return False
|
||||
Job = self.env['fp.job']
|
||||
jobs = Job.search([('sale_order_id', '=', self.id)])
|
||||
deliveries = Delivery.browse([])
|
||||
if 'x_fc_job_id' in Delivery._fields:
|
||||
deliveries = Delivery.search([
|
||||
('x_fc_job_id', 'in', jobs.ids),
|
||||
('state', '!=', 'delivered'),
|
||||
])
|
||||
for dlv in deliveries:
|
||||
dlv.action_mark_delivered()
|
||||
self.message_post(
|
||||
body=_(
|
||||
'%d delivery record(s) marked delivered. '
|
||||
'Invoice flow triggered per invoice strategy.'
|
||||
) % len(deliveries),
|
||||
)
|
||||
return True
|
||||
|
||||
def action_fp_open_shop_floor(self):
|
||||
"""Jump to the Plant Overview filtered to this SO's jobs."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_plant_overview',
|
||||
'name': _('Shop Floor - %s') % self.name,
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Mid-job qty drift guard. When Sarah edits an SO line's qty after a
|
||||
# fp.job has been spawned and started, the job's qty does NOT auto-
|
||||
# update (intentionally - Carlos may already be plating). But without
|
||||
# a warning the qty drift is silent and bills go out wrong. This
|
||||
# write-override posts chatter on every active linked job so operators
|
||||
# see the change immediately, AND offers a "Sync qty from SO" action
|
||||
# on the job for the supervisor to apply.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, models
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
def write(self, vals):
|
||||
# Detect qty changes BEFORE the write so we can compare.
|
||||
old_qty_by_id = {}
|
||||
if 'product_uom_qty' in vals:
|
||||
for line in self:
|
||||
old_qty_by_id[line.id] = line.product_uom_qty
|
||||
result = super().write(vals)
|
||||
# Recipe set/changed late on the line -> heal the linked WO that
|
||||
# was created empty before the estimator picked the process. Only
|
||||
# not-yet-started jobs (no steps) are touched.
|
||||
if 'x_fc_process_variant_id' in vals:
|
||||
Job = self.env['fp.job']
|
||||
for line in self:
|
||||
jobs = Job.search([
|
||||
('sale_order_line_ids', 'in', line.id),
|
||||
('state', 'not in', ('done', 'cancelled')),
|
||||
])
|
||||
for job in jobs.filtered(lambda j: not j.step_ids):
|
||||
job.sudo()._fp_resync_recipe_from_so()
|
||||
if 'product_uom_qty' not in vals:
|
||||
return result
|
||||
Job = self.env['fp.job']
|
||||
for line in self:
|
||||
new_qty = line.product_uom_qty
|
||||
old_qty = old_qty_by_id.get(line.id, new_qty)
|
||||
if old_qty == new_qty:
|
||||
continue
|
||||
jobs = Job.search([
|
||||
('sale_order_id', '=', line.order_id.id),
|
||||
('state', 'not in', ('draft', 'cancelled', 'done')),
|
||||
])
|
||||
for job in jobs:
|
||||
job.message_post(body=Markup(_(
|
||||
'⚠️ <b>SO qty changed mid-job</b> by %(user)s. '
|
||||
'SO line %(name)s went from %(old)g to %(new)g. '
|
||||
'Job qty is still <b>%(jobqty)g</b> - operator '
|
||||
'must manually adjust scope (start more racks or '
|
||||
'stop early) and the supervisor should hit '
|
||||
'<b>Sync qty from SO</b> on the job header to '
|
||||
'reconcile.'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'name': line.name[:60] if line.name else '(unnamed)',
|
||||
'old': old_qty,
|
||||
'new': new_qty,
|
||||
'jobqty': job.qty,
|
||||
})
|
||||
return result
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="action_report_fp_job_margin" model="ir.actions.report">
|
||||
<field name="name">Job Margin Report</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_margin_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_margin_template</field>
|
||||
<field name="print_report_name">'Job Margin - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_margin_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="rows" t-as="row">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Job Margin - <span t-esc="row['job'].name"/></h2>
|
||||
<table class="table table-sm" style="margin-top: 1em; max-width: 600px;">
|
||||
<tr><th>Customer</th><td><span t-esc="row['job'].partner_id.name"/></td></tr>
|
||||
<tr><th>Recipe</th><td><span t-esc="row['job'].recipe_id.name or '-'"/></td></tr>
|
||||
<tr><th>Quantity</th><td><span t-esc="row['job'].qty"/></td></tr>
|
||||
<tr><th>Status</th><td><span t-esc="row['job'].state"/></td></tr>
|
||||
</table>
|
||||
|
||||
<h3 style="margin-top: 1.5em;">Step Breakdown</h3>
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Step</th>
|
||||
<th>Work Centre</th>
|
||||
<th class="text-end">Expected (min)</th>
|
||||
<th class="text-end">Actual (min)</th>
|
||||
<th class="text-end">Rate / hr</th>
|
||||
<th class="text-end">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="row['steps']" t-as="step">
|
||||
<tr>
|
||||
<td><span t-esc="step['sequence']"/></td>
|
||||
<td><span t-esc="step['name']"/></td>
|
||||
<td><span t-esc="step['work_centre']"/></td>
|
||||
<td class="text-end"><span t-esc="step['duration_expected']"/></td>
|
||||
<td class="text-end"><span t-esc="step['duration_actual']"/></td>
|
||||
<td class="text-end"><span t-esc="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
<td class="text-end"><span t-esc="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr style="font-weight: bold; background: #f3f3f3;">
|
||||
<td colspan="3">Totals</td>
|
||||
<td></td>
|
||||
<td class="text-end"><span t-esc="row['total_minutes']"/></td>
|
||||
<td></td>
|
||||
<td class="text-end"><span t-esc="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="margin-top: 1.5em;">Margin Summary</h3>
|
||||
<table class="table table-sm" style="max-width: 400px;">
|
||||
<tr><th>Quoted Revenue</th><td class="text-end"><span t-esc="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr><th>Actual Cost</th><td class="text-end"><span t-esc="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-esc="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||
<tr><th>Margin %</th><td class="text-end"><span t-esc="round(row['margin_pct'], 1)"/>%</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,260 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Redesigned job stickers (thermal label, 6x4 in / 152x102 mm):
|
||||
* Internal Job Sticker - Layout A (stacked, full-width notes),
|
||||
ONE label per job. Shop copy: x_fc_internal_description notes,
|
||||
job QR (/fp/job/<id>).
|
||||
* External Job Sticker - Layout B (left rail + tall notes),
|
||||
ONE label per fp.box. Customer copy: factory logo, BOX n/N,
|
||||
per-box QR (/fp/box/<id>), customer-facing description notes.
|
||||
|
||||
Dynamic: MASK badge when masking enabled, BAKE block when bake
|
||||
instructions present, length-tiered notes font. Field resolution +
|
||||
short-code + cleanup live in fp.job._fp_sticker_data() (Python).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="paperformat_fp_job_sticker" model="report.paperformat">
|
||||
<field name="name">FP Job Sticker (6x4")</field>
|
||||
<field name="format">custom</field>
|
||||
<field name="page_width">152</field>
|
||||
<field name="page_height">102</field>
|
||||
<field name="orientation">Portrait</field>
|
||||
<field name="margin_top">0</field>
|
||||
<field name="margin_bottom">0</field>
|
||||
<field name="margin_left">0</field>
|
||||
<field name="margin_right">0</field>
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">0</field>
|
||||
<field name="disable_shrinking" eval="True"/>
|
||||
<!-- dpi=96 (NOT 300): this label is laid out in mm (matches the
|
||||
approved Chrome-rendered mockups). At dpi=300 wkhtmltopdf shrinks
|
||||
mm content to ~96/300 of true size (CLAUDE.md rule 14). 96 maps
|
||||
mm 1:1 so it fills the page; QR/logo stay crisp (embedded at their
|
||||
own resolution, text is vector). Legacy px-based stickers keep 300. -->
|
||||
<field name="dpi">96</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
<field name="name">External Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_template</field>
|
||||
<field name="print_report_name">'External Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<!-- NOT bound to the fp.job Print menu (removed from the list per
|
||||
request). Printed via the Receiving screen button
|
||||
(fp.receiving.action_print_internal_sticker). eval=False clears
|
||||
any binding a prior install left in the DB. -->
|
||||
<field name="binding_model_id" eval="False"/>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================ Shared CSS ============================ -->
|
||||
<template id="fp_job_sticker_styles">
|
||||
<style>
|
||||
@page { size: 152mm 102mm; margin: 0; }
|
||||
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
|
||||
.label-page { width: 152mm; height: 102mm; position: relative; overflow: hidden; page-break-after: always; }
|
||||
.label { position: absolute; top: 2mm; left: 2mm; right: 2mm; bottom: 2mm; border: 0.9mm solid #000; overflow: hidden; }
|
||||
.fpt { border-collapse: collapse; width: 100%; }
|
||||
.fpt td { vertical-align: middle; }
|
||||
.lbl { font-size: 7.5pt; font-weight: bold; letter-spacing: 0.4pt; text-transform: uppercase; display: block; }
|
||||
.band { background: #000; color: #fff; }
|
||||
.pad { padding: 1mm 2.5mm; }
|
||||
.vrule { border-right: 0.5mm solid #000; }
|
||||
.rule { border-bottom: 0.6mm solid #000; }
|
||||
.badge { display: inline-block; background: #000; color: #fff; font-size: 10pt; font-weight: 900; padding: 0.6mm 2.2mm; margin-left: 1.5mm; }
|
||||
.tag { display: inline-block; background: transparent; color: #fff; border: 0.5mm solid #fff; font-size: 8pt; font-weight: bold; padding: 0.4mm 2mm; }
|
||||
.inshead { font-size: 8.5pt; font-weight: 900; letter-spacing: 0.6pt; background: #000; color: #fff; display: inline-block; padding: 0.5mm 2.5mm; }
|
||||
.instext { font-weight: bold; }
|
||||
/* Layout B rail + main */
|
||||
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
|
||||
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
|
||||
.r-logo { height: 9mm; line-height: 9mm; text-align: center; }
|
||||
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
|
||||
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
|
||||
.r-qrflags { height: 32mm; text-align: center; }
|
||||
.qftbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.qftbl td { vertical-align: middle; text-align: center; }
|
||||
.qfqr { width: 66%; }
|
||||
<!-- QR quiet-zone crop: the barcode bakes a ~12% white border
|
||||
around the pattern. Render the image oversized in an
|
||||
overflow:hidden wrapper, offset so the wrapper clips ~10% off
|
||||
each edge (under the quiet zone, so no modules are lost) - the
|
||||
black pattern then fills the box. White label cell around the
|
||||
wrapper still provides the scan margin. CLAUDE.md rule 14. -->
|
||||
.qfwrap-qr { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
|
||||
.qftags { width: 34%; border-left: 0.5mm solid #000; }
|
||||
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
|
||||
.qffull { line-height: 32mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
|
||||
/* Internal (Layout A) header QR - same ~10% quiet-zone crop. Trimmed
|
||||
30mm -> 27mm so the QR-driven black band is shorter and the freed
|
||||
height flows to the NOTES block below. Still well above scan size. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 33.75mm; height: 33.75mm; top: -3.375mm; left: -3.375mm; }
|
||||
.r-fld { padding: 0.7mm 2.2mm; }
|
||||
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.m-thk { padding: 1.8mm 2.6mm 2.4mm; }
|
||||
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External body - Layout B ===================== -->
|
||||
<template id="fp_job_external_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<div class="rail">
|
||||
<div class="r-logo rule">
|
||||
<img t-if="_logo" t-att-src="image_data_uri(_logo)"/>
|
||||
<span t-if="not _logo" style="font-size:11pt;font-weight:900"><t t-esc="d['customer_full'][:18]"/></span>
|
||||
</div>
|
||||
<div class="r-wo">
|
||||
<table class="wobtbl"><tr>
|
||||
<td class="vrule" style="width:52%;border-right-color:#fff">
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<span class="bignum"><t t-esc="d['wo'].split('-')[-1].split('/')[-1]"/></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="lbl" style="color:#fff">Box</span>
|
||||
<span class="bignum"><t t-esc="_box_num"/> / <t t-esc="_box_cnt"/></span>
|
||||
</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
<div class="r-qrflags rule">
|
||||
<t t-if="d['mask'] or d['bake']">
|
||||
<table class="qftbl"><tr>
|
||||
<td class="qfqr"><span class="qfwrap-qr"><img t-att-src="_qr"/></span></td>
|
||||
<td class="qftags">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</td>
|
||||
</tr></table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<table class="qftbl"><tr><td><span class="qfwrap-full"><img t-att-src="_qr"/></span></td></tr></table>
|
||||
</t>
|
||||
</div>
|
||||
<div class="r-fld rule">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:11.5pt;font-weight:900"><t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:8.5pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
</div>
|
||||
<div class="r-fld rule"><span class="lbl">Customer</span><span style="font-size:11pt;font-weight:900"><t t-esc="d['customer']"/></span></div>
|
||||
<div class="rule" style="height:8.5mm"><table class="gtbl"><tr>
|
||||
<td class="vrule" style="width:55%"><span class="lbl">PO#</span><span style="font-size:9.5pt;font-weight:bold;display:block"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
|
||||
</tr></table></div>
|
||||
<div style="height:8.5mm"><table class="gtbl"><tr>
|
||||
<td><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
</tr></table></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<!-- Plating thickness - the team's most-watched spec. Relocated
|
||||
out of the cramped rail to a prominent full-width banner at
|
||||
the top of the main panel. -->
|
||||
<t t-if="d['has_thk']">
|
||||
<div class="m-thk rule"><span class="inshead">PLATING THICKNESS</span>
|
||||
<div style="font-size:21pt;font-weight:900;line-height:1;margin-top:1.4mm"><t t-esc="d['thk']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="d['bake']">
|
||||
<div class="m-bake rule"><span class="inshead">BAKE</span>
|
||||
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="m-notes"><span class="inshead"><t t-esc="_notes_label or 'NOTES'"/></span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal outer (per job) ===================== -->
|
||||
<!-- Internal sticker = a COPY of the External (Layout B, one per box) but
|
||||
showing the INTERNAL description instead of the customer-facing notes,
|
||||
and a "INTERNAL NOTES" header so the shop copy can't be confused with
|
||||
the customer copy. Identical rail/QR/logo/box otherwise. -->
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['internal_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'INTERNAL NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External outer (per box) ===================== -->
|
||||
<template id="report_fp_job_sticker_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['customer_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,337 +0,0 @@
|
||||
<?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.
|
||||
|
||||
Sub 12c v2 - paper-style A4 landscape job traveller.
|
||||
Mirrors the Amphenol Canada paper sheets (Steelhead screens 16-18):
|
||||
barcode + WO header, item-info block, recipe sub-process header, then
|
||||
the routing table with target ranges + actuals + sign-off cells per
|
||||
step. Operators print one of these per job, pencil in actuals; the
|
||||
tablet captures the same data digitally - printed traveller is the
|
||||
redundant audit copy.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="paperformat_fp_traveller_landscape" model="report.paperformat">
|
||||
<field name="name">FP Traveller - A4 landscape narrow margins</field>
|
||||
<field name="format">A4</field>
|
||||
<field name="orientation">Landscape</field>
|
||||
<!-- margin_top + header_spacing both reserve room above the body
|
||||
so the H1 / Item Information table doesn't ride into the
|
||||
external_layout's company logo band. The screenshot showed
|
||||
"Work Order / Bon de Travail" overlapping the ENTECH logo
|
||||
with the prior 10 / 5 values; 28 / 22 buys ~1cm clear gap. -->
|
||||
<field name="margin_top">28</field>
|
||||
<field name="margin_bottom">10</field>
|
||||
<field name="margin_left">8</field>
|
||||
<field name="margin_right">8</field>
|
||||
<field name="header_spacing">22</field>
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_traveller" model="ir.actions.report">
|
||||
<field name="name">Job Traveller</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_traveller_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_traveller_template</field>
|
||||
<field name="print_report_name">'Traveller - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_traveller_landscape"/>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_traveller_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page fp-trav-page">
|
||||
<style>
|
||||
.fp-trav-page { font-family: Arial, sans-serif; font-size: 8pt; color: #000; }
|
||||
.fp-trav-page h1 { font-size: 14pt; margin: 0; }
|
||||
.fp-trav-page table.bordered,
|
||||
.fp-trav-page table.bordered th,
|
||||
.fp-trav-page table.bordered td { border: 1px solid #000; border-collapse: collapse; }
|
||||
.fp-trav-page table.bordered { width: 100%; border-collapse: collapse; }
|
||||
.fp-trav-page table.bordered th { background: #ededed; padding: 4px 6px; text-align: left; font-weight: bold; font-size: 8pt; }
|
||||
.fp-trav-page table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8pt; }
|
||||
.fp-trav-page .fp-trav-actuals { font-size: 7.5pt; color: #555; line-height: 1.5; }
|
||||
.fp-trav-page .fp-trav-target { color: #444; font-size: 7.5pt; }
|
||||
.fp-trav-page .fp-trav-blank { display: inline-block; min-width: 32mm; border-bottom: 1px solid #888; height: 1.2em; }
|
||||
.fp-trav-page .fp-trav-stamp { min-height: 12mm; }
|
||||
.fp-trav-page .text-center { text-align: center; }
|
||||
</style>
|
||||
|
||||
<!-- ===== HEADER ===== -->
|
||||
<table class="bordered">
|
||||
<tr>
|
||||
<td style="width: 8%; vertical-align: middle; text-align: center;">
|
||||
<img t-if="job.company_id.logo"
|
||||
t-att-src="'data:image/png;base64,%s' % job.company_id.logo.decode()"
|
||||
style="max-width: 28mm; max-height: 18mm;"/>
|
||||
</td>
|
||||
<td colspan="2" style="vertical-align: middle; width: 28%;">
|
||||
<h1>Work Order / Bon de Travail</h1>
|
||||
<div class="text-center" style="margin-top: 4px;">
|
||||
<strong t-esc="job.name"/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<img t-att-src="'/report/barcode/Code128/%s' % job.name"
|
||||
style="height: 14mm;"/>
|
||||
</div>
|
||||
</td>
|
||||
<td style="width: 18%;">
|
||||
<strong>Date In:</strong>
|
||||
<span t-esc="job.create_date and job.create_date.strftime('%d-%m-%Y') or '-'"/><br/>
|
||||
<strong>Due Date:</strong>
|
||||
<span t-esc="job.date_deadline and job.date_deadline.strftime('%d-%m-%Y') or '-'"/><br/>
|
||||
<strong>Type:</strong>
|
||||
<span t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/>
|
||||
</td>
|
||||
<td style="width: 18%;">
|
||||
<strong>Order #:</strong>
|
||||
<span t-esc="(job.sale_order_id and job.sale_order_id.name) or '-'"/><br/>
|
||||
<strong>P.O. #:</strong>
|
||||
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '-'"/><br/>
|
||||
<strong>WO Generated By:</strong>
|
||||
<span t-esc="(job.create_uid and job.create_uid.name) or '-'"/>
|
||||
</td>
|
||||
<td style="width: 28%; vertical-align: top;">
|
||||
<strong t-esc="(job.partner_id and job.partner_id.name) or '-'"/><br/>
|
||||
<span t-esc="(job.partner_id and job.partner_id.street) or ''"/><br/>
|
||||
<span t-esc="(job.partner_id and job.partner_id.city) or ''"/>,
|
||||
<span t-esc="(job.partner_id and job.partner_id.state_id and job.partner_id.state_id.code) or ''"/>
|
||||
<span t-esc="(job.partner_id and job.partner_id.zip) or ''"/><br/>
|
||||
<strong>Tel:</strong>
|
||||
<span t-esc="(job.partner_id and job.partner_id.phone) or '-'"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- ===== ITEM INFORMATION ===== -->
|
||||
<table class="bordered" style="margin-top: 4px;">
|
||||
<tr>
|
||||
<th style="width: 22%;">Item Information</th>
|
||||
<th style="width: 30%;">Item-Name / Process Description</th>
|
||||
<th style="width: 8%;">Qty Rec.</th>
|
||||
<th style="width: 6%;">Vis Insp</th>
|
||||
<th style="width: 6%;">Rework</th>
|
||||
<th style="width: 22%;">Special Requirements</th>
|
||||
<th style="width: 6%;">Stamp / Date</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Part #:</strong>
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
||||
<span t-esc="job.part_catalog_id.part_number or '-'"/>
|
||||
</t>
|
||||
<t t-else="">-</t><br/>
|
||||
<strong>Rev:</strong>
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id and 'revision' in job.part_catalog_id._fields">
|
||||
<span t-esc="job.part_catalog_id.revision or '-'"/>
|
||||
</t>
|
||||
<t t-else="">-</t><br/>
|
||||
<strong>Mat:</strong>
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id and 'base_material' in job.part_catalog_id._fields">
|
||||
<span t-esc="job.part_catalog_id.base_material or '-'"/>
|
||||
</t>
|
||||
<t t-else="">-</t><br/>
|
||||
<strong>Catg:</strong>
|
||||
<span t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/><br/>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
||||
<span t-esc="job.part_catalog_id.name or job.product_id.name or '-'"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="(job.product_id and job.product_id.name) or '-'"/>
|
||||
</t>
|
||||
</strong>
|
||||
<div style="font-size: 7.5pt; margin-top: 2px;">
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id and 'customer_facing_description' in job.part_catalog_id._fields">
|
||||
<span t-esc="job.part_catalog_id.customer_facing_description or ''"
|
||||
style="white-space: pre-wrap;"/>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="(job.qty_received if 'qty_received' in job._fields else 0) or job.qty"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-if="'qty_visual_inspection_rejects' in job._fields">
|
||||
<span t-esc="job.qty_visual_inspection_rejects or 0"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-if="'qty_rework' in job._fields">
|
||||
<span t-esc="job.qty_rework or 0"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td style="font-size: 7pt; white-space: pre-wrap;">
|
||||
<t t-if="'special_requirements' in job._fields">
|
||||
<span t-esc="job.special_requirements or '-'"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="fp-trav-stamp"/>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- ===== PROCESS-SHEET HEADER ===== -->
|
||||
<table class="bordered" style="margin-top: 4px;">
|
||||
<tr>
|
||||
<th style="width: 30%;">Process Sheet / Feuille de Procédé</th>
|
||||
<th style="width: 20%;">Catg.</th>
|
||||
<th style="width: 50%;">Spec / Info</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/></td>
|
||||
<td>
|
||||
<t t-if="job.recipe_id and job.recipe_id.process_type_id">
|
||||
<span t-esc="job.recipe_id.process_type_id.name"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
|
||||
<span t-esc="job.customer_spec_id.display_name"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- ===== ROUTING TABLE =====
|
||||
Continues on subsequent pages - paperformat handles
|
||||
page break automatically. Footer (Ship Order To +
|
||||
Additional Notes) closes the document. -->
|
||||
|
||||
<!-- inline routing follows; footer appears below -->
|
||||
<!-- (placed after the routing table - see end-of-template) -->
|
||||
|
||||
<!-- ===== ROUTING TABLE ===== -->
|
||||
<table class="bordered" style="margin-top: 4px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 3%;">Step</th>
|
||||
<th style="width: 6%;">Tank</th>
|
||||
<th style="width: 22%;">Operation + Actuals</th>
|
||||
<th style="width: 22%;">Instruction</th>
|
||||
<th style="width: 5%;">Unit</th>
|
||||
<th style="width: 8%;">Material</th>
|
||||
<th style="width: 6%;">Voltage</th>
|
||||
<th style="width: 7%;">Time (min)</th>
|
||||
<th style="width: 7%;">Temp</th>
|
||||
<th style="width: 6%;">Stamp</th>
|
||||
<th style="width: 8%;">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="job.step_ids.sorted('sequence')" t-as="step">
|
||||
<t t-set="rn" t-value="step.recipe_node_id"/>
|
||||
<tr>
|
||||
<td class="text-center"><span t-esc="step_index + 1"/></td>
|
||||
<td class="text-center">
|
||||
<span t-esc="(step.tank_id and step.tank_id.code) or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<strong t-esc="step.name"/>
|
||||
<div class="fp-trav-actuals">
|
||||
<t t-if="rn">
|
||||
<t t-foreach="rn.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')" t-as="inp">
|
||||
<span t-esc="inp.name"/>:
|
||||
<span class="fp-trav-blank"/>
|
||||
<t t-if="'target_unit' in inp._fields and inp.target_unit"><span> </span><span t-esc="inp.target_unit"/></t><br/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size: 7.5pt; white-space: pre-wrap;">
|
||||
<t t-if="rn and rn.description">
|
||||
<span t-esc="rn.description" t-options="{'widget': 'html'}"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-center fp-trav-target">
|
||||
<t t-if="rn and 'time_unit' in rn._fields and rn.time_unit">
|
||||
<span t-esc="rn.time_unit"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="text-center fp-trav-target">
|
||||
<t t-if="rn and 'material_callout' in rn._fields and rn.material_callout">
|
||||
<span t-esc="rn.material_callout"/>
|
||||
</t>
|
||||
<t t-elif="rn and rn.process_type_id">
|
||||
<span t-esc="rn.process_type_id.name"/>
|
||||
</t>
|
||||
<t t-else="">N/A</t>
|
||||
</td>
|
||||
<td class="text-center fp-trav-target">
|
||||
<t t-if="rn and 'voltage_target' in rn._fields and rn.voltage_target">
|
||||
<span t-esc="rn.voltage_target"/>V
|
||||
</t>
|
||||
<t t-else="">N/A</t>
|
||||
</td>
|
||||
<td class="text-center fp-trav-target">
|
||||
<t t-if="rn and 'time_min_target' in rn._fields and rn.time_max_target">
|
||||
<span t-esc="rn.time_min_target"/> - <span t-esc="rn.time_max_target"/>
|
||||
</t>
|
||||
<t t-else="">N/A</t>
|
||||
</td>
|
||||
<td class="text-center fp-trav-target">
|
||||
<t t-if="rn and 'temp_min_target' in rn._fields and rn.temp_max_target">
|
||||
<span t-esc="rn.temp_min_target"/>-<span t-esc="rn.temp_max_target"/>
|
||||
<span t-if="rn.temp_unit" t-esc="rn.temp_unit"/>
|
||||
</t>
|
||||
<t t-else="">N/A</t>
|
||||
</td>
|
||||
<td class="fp-trav-stamp"/>
|
||||
<td class="fp-trav-stamp"/>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ===== FOOTER - SHIP ORDER + NOTES ===== -->
|
||||
<table class="bordered" style="margin-top: 6px;">
|
||||
<tr>
|
||||
<th style="width: 30%;">Ship Order To</th>
|
||||
<th style="width: 70%;">Additional Notes</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: top;">
|
||||
<strong t-esc="(job.partner_id and job.partner_id.name) or '-'"/><br/>
|
||||
<span t-esc="(job.partner_id and job.partner_id.street) or ''"/><br/>
|
||||
<span t-if="job.partner_id and job.partner_id.street2"
|
||||
t-esc="job.partner_id.street2"/>
|
||||
<t t-if="job.partner_id and job.partner_id.street2"><br/></t>
|
||||
<span t-esc="(job.partner_id and job.partner_id.city) or ''"/>,
|
||||
<span t-esc="(job.partner_id and job.partner_id.state_id and job.partner_id.state_id.code) or ''"/>
|
||||
<span t-esc="(job.partner_id and job.partner_id.zip) or ''"/><br/>
|
||||
<span t-esc="(job.partner_id and job.partner_id.country_id and job.partner_id.country_id.code) or ''"/>
|
||||
</td>
|
||||
<td class="fp-trav-stamp" style="min-height: 18mm;">
|
||||
<t t-if="'special_requirements' in job._fields and job.special_requirements">
|
||||
<span t-esc="job.special_requirements"
|
||||
style="white-space: pre-wrap; font-size: 7.5pt;"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align: center; margin-top: 4px; font-size: 7pt; color: #666;">
|
||||
<span t-esc="job.name"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,615 +0,0 @@
|
||||
<?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.
|
||||
|
||||
Steelhead-style "Work Order Detail" PDF - the post-job audit cert
|
||||
that walks fp.job.step.move records chronologically, lists captured
|
||||
inputs per step, and ends with a Certified By + Cert Statement
|
||||
page. Bound to fp.job directly (not fp.certificate) so a manager
|
||||
can print the audit document straight from the job form.
|
||||
|
||||
Layout mirrors the customer-shared `job card.pdf` (see CLAUDE.md
|
||||
Sub 12c). Reuses the same per-customer cert-statement resolution
|
||||
chain (partner.x_fc_cert_statement → company.x_fc_default_cert_statement
|
||||
→ hardcoded boilerplate) so we don't fork two cert templates.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="paperformat_fp_wo_detail" model="report.paperformat">
|
||||
<field name="name">FP Work Order Detail - A4 portrait</field>
|
||||
<field name="format">A4</field>
|
||||
<field name="orientation">Portrait</field>
|
||||
<!-- margin_top + header_spacing both reserve room above the body
|
||||
content. The external_layout puts the company logo + address
|
||||
in that band; without enough space the header overlaps the
|
||||
body's first line (the H1 on page 1, the Certified By table
|
||||
on page 2). 35 / 28 puts a clean ~1cm clear gap below the
|
||||
logo block. -->
|
||||
<field name="margin_top">35</field>
|
||||
<field name="margin_bottom">15</field>
|
||||
<field name="margin_left">12</field>
|
||||
<field name="margin_right">12</field>
|
||||
<field name="header_spacing">28</field>
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_wo_detail" model="ir.actions.report">
|
||||
<field name="name">Work Order Detail</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_wo_detail_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_wo_detail_template</field>
|
||||
<field name="print_report_name">'WO Detail - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_detail"/>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_wo_detail_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="company" t-value="job.company_id"/>
|
||||
<t t-set="so" t-value="job.sale_order_id"/>
|
||||
<!-- All datetimes in Postgres are naive UTC. QWeb's
|
||||
eval scope exposes neither pytz nor format_datetime,
|
||||
so timestamp formatting happens via job.fp_format_local()
|
||||
on the record itself - record methods are always
|
||||
available in templates. The helper resolves user.tz
|
||||
→ company.x_fc_default_tz → UTC. -->
|
||||
|
||||
<!-- First SO line linked to this job - source of truth
|
||||
for the customer-facing description, serial(s),
|
||||
and part metadata. -->
|
||||
<t t-set="primary_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="po_number"
|
||||
t-value="(so and (so.client_order_ref or (
|
||||
'x_fc_po_number' in so._fields and so.x_fc_po_number) or ''))
|
||||
or ''"/>
|
||||
<t t-set="customer_desc"
|
||||
t-value="primary_line and primary_line.fp_customer_description() or ''"/>
|
||||
<t t-set="serial_names"
|
||||
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
|
||||
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
|
||||
or ''"/>
|
||||
<!-- Customer-facing WO id: strip the sequence prefix
|
||||
("WH/JOB/01373" → "01373"). Keeps the column / cert
|
||||
reference compact; full job.name is still used
|
||||
internally and on the print_report_name. -->
|
||||
<!-- WO display: strip the model prefix so the Work
|
||||
Order column shows "30000" or "30000-02" instead
|
||||
of "WO-30000". Handles both naming schemes:
|
||||
- new "WO-NNNNN[-NN]" (post 2026-05-12 numbering)
|
||||
- legacy "WH/JOB/NNNNN" (pre-2026-05-12 jobs) -->
|
||||
<t t-set="short_wo" t-value="(
|
||||
job.name and job.name.startswith('WO-') and job.name[3:]
|
||||
or (job.name or '').split('/')[-1]
|
||||
)"/>
|
||||
|
||||
<!-- Photo evidence - collect every captured-input value
|
||||
that has an attachment, in step / time order. We
|
||||
number them globally (Photo 1..N) and use those
|
||||
numbers both in the per-step measurement tables
|
||||
(so the customer can see at a glance "this prompt
|
||||
has a photo, see #3 below") and as the gallery
|
||||
titles at the end of the report. The dict carries
|
||||
the cv record, its 1-based index, and pre-computed
|
||||
caption fields so the gallery loop stays clean. -->
|
||||
<t t-set="all_photo_values"
|
||||
t-value="job.move_ids
|
||||
.sorted('move_datetime')
|
||||
.mapped('transition_input_value_ids')
|
||||
.filtered(lambda v: v.value_attachment_id)"/>
|
||||
<t t-set="photo_index_by_id" t-value="{cv.id: idx + 1 for idx, cv in enumerate(all_photo_values)}"/>
|
||||
<!-- Walk EVERY step in sequence, not just moves. The
|
||||
old report only rendered moves so steps without
|
||||
recorded measurements (just Finish & Next) never
|
||||
appeared on the cert. -->
|
||||
<t t-set="all_steps" t-value="job.step_ids.filtered(
|
||||
lambda s: s.state not in ('cancelled',)
|
||||
).sorted('sequence')"/>
|
||||
|
||||
<div class="page fp-wo-detail">
|
||||
<style>
|
||||
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
|
||||
.fp-wo-detail h1 { text-align: center; font-size: 22pt; margin: 0 0 14px 0; font-weight: bold; color: #2e2e2e; }
|
||||
.fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; }
|
||||
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
|
||||
.fp-wo-detail table.bordered,
|
||||
.fp-wo-detail table.bordered th,
|
||||
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
|
||||
.fp-wo-detail table.bordered { width: 100%; margin-bottom: 8px; }
|
||||
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
|
||||
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
|
||||
.fp-wo-detail .text-center { text-align: center; }
|
||||
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 12px 0; }
|
||||
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
|
||||
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
|
||||
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
|
||||
/* Photo gallery - bordered tile per attachment.
|
||||
flex-wrap so wkhtmltopdf lays out two per row
|
||||
on A4 portrait; page-break-inside on the tile
|
||||
keeps captions glued to their image. */
|
||||
.fp-wo-detail .fp-photo-section { margin-top: 18px; }
|
||||
.fp-wo-detail .fp-photo-section h2 {
|
||||
font-size: 13pt; font-weight: bold; color: #2e2e2e;
|
||||
margin: 0 0 8px 0; border-bottom: 2px solid #c1c1c1;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-tile {
|
||||
border: 1px solid #000; padding: 6px;
|
||||
width: 86mm; box-sizing: border-box;
|
||||
page-break-inside: avoid; background: #fff;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap {
|
||||
width: 100%; height: 70mm;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #f5f5f5; border: 1px solid #d0d0d0;
|
||||
overflow: hidden; margin-bottom: 4px;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-tile .fp-photo-imgwrap img {
|
||||
max-width: 100%; max-height: 100%; object-fit: contain;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-title {
|
||||
font-size: 9pt; font-weight: bold; margin: 2px 0;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-desc {
|
||||
font-size: 8pt; color: #444; line-height: 1.25;
|
||||
}
|
||||
.fp-wo-detail .fp-photo-ref {
|
||||
font-size: 8pt; color: #4e4e4e; font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Inline signature image inside the step
|
||||
measurement Value cell - rendered when a
|
||||
`signature` prompt has a recorder with a
|
||||
Plating Signature on file. Sized to fit the
|
||||
table row without blowing it up. */
|
||||
.fp-wo-detail img.fp-sig-inline {
|
||||
max-height: 14mm; max-width: 50mm;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Work Order Detail</h1>
|
||||
|
||||
<!-- ===== HEADER - Prepared For + summary table ===== -->
|
||||
<div class="fp-prepared">
|
||||
<strong>Prepared For:</strong>
|
||||
<span style="font-size: 11pt;"
|
||||
t-esc="(job.partner_id and job.partner_id.name) or '-'"/>
|
||||
</div>
|
||||
|
||||
<table class="bordered">
|
||||
<tr>
|
||||
<th style="width: 18%;">Part Number</th>
|
||||
<th style="width: 30%;">Description</th>
|
||||
<th style="width: 7%;">Quantity</th>
|
||||
<th style="width: 11%;">Work Order</th>
|
||||
<th style="width: 12%;">PO Number</th>
|
||||
<th style="width: 12%;">Serial No</th>
|
||||
<th style="width: 10%;">Date</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
||||
<span t-esc="job.part_catalog_id.part_number or '-'"/>
|
||||
<t t-if="'revision' in job.part_catalog_id._fields and job.part_catalog_id.revision">
|
||||
<br/>
|
||||
<span style="font-size: 7.5pt;">Rev <span t-esc="job.part_catalog_id.revision"/></span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="(job.product_id and job.product_id.default_code) or '-'"/>
|
||||
</t>
|
||||
</td>
|
||||
<td style="vertical-align: top;">
|
||||
<!-- Customer-facing description. The
|
||||
pre-line wrapper lives on an
|
||||
INNER div, not the <td>: keeping
|
||||
pre-line on the cell rendered
|
||||
the indentation between <td>
|
||||
and <t t-if> as literal blank
|
||||
lines, pushing the description
|
||||
halfway down the cell. The div
|
||||
only sees the t-esc'd text, so
|
||||
pre-line preserves the operator's
|
||||
intentional \n\n paragraph
|
||||
breaks but nothing else. -->
|
||||
<div style="white-space: pre-line;"><t t-if="customer_desc"><span t-esc="customer_desc.strip()"/></t><t t-elif="'part_catalog_id' in job._fields and job.part_catalog_id"><span t-esc="job.part_catalog_id.name or job.product_id.name or '-'"/></t><t t-else=""><span t-esc="(job.product_id and job.product_id.name) or '-'"/></t></div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="job.qty"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="short_wo"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="po_number or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="serial_names or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-set="_hdr_dt"
|
||||
t-value="job.date_finished or job.date_started or job.create_date"/>
|
||||
<span t-esc="job.fp_format_local(_hdr_dt, '%Y-%m-%d')"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- ===== Contract Review (QA-005) block =====
|
||||
Surfaces the part's QA-005 audit trail on
|
||||
every WO Detail print. Per Rule 4 of the
|
||||
contract-review flow, on repeat orders the
|
||||
contract-review WO step is auto-completed
|
||||
at job creation using the reviewer's
|
||||
identity + date from the existing review;
|
||||
this block makes that audit visible to
|
||||
customers and inspectors. Hidden when no
|
||||
review exists (e.g. customer doesn't
|
||||
require contract review). -->
|
||||
<t t-set="review"
|
||||
t-value="('part_catalog_id' in job._fields and job.part_catalog_id
|
||||
and 'x_fc_contract_review_id' in job.part_catalog_id._fields
|
||||
and job.part_catalog_id.x_fc_contract_review_id) or False"/>
|
||||
<t t-if="review">
|
||||
<t t-set="_signer"
|
||||
t-value="review.s30_signed_by or review.s20_signed_by"/>
|
||||
<t t-set="_signed_dt"
|
||||
t-value="review.s30_signed_date or review.s20_signed_date"/>
|
||||
<t t-set="_initials_src"
|
||||
t-value="(_signer and _signer.name) or ''"/>
|
||||
<t t-set="_initials"
|
||||
t-value="''.join([w[:1].upper() for w in _initials_src.split()[:3]])"/>
|
||||
<table class="bordered" style="margin-top: 8px;">
|
||||
<tr>
|
||||
<th colspan="4" style="font-size: 10pt;">Contract Review (QA-005)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 28%;">Status</th>
|
||||
<th style="width: 30%;">Reviewer</th>
|
||||
<th style="width: 14%;">Initials</th>
|
||||
<th style="width: 28%;">Date Reviewed</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<t t-if="review.state == 'complete'">
|
||||
<span style="font-weight: bold; color: #2e2e2e;">QA-005 Approved</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="color: #aa0000;">Pending - <span t-esc="dict(review._fields['state'].selection).get(review.state, review.state)"/></span>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="(_signer and _signer.name) or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<span style="font-weight: bold;" t-esc="_initials or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="_signed_dt">
|
||||
<span t-esc="job.fp_format_local(_signed_dt, '%Y-%m-%d')"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<div class="fp-spec">Specification(s):
|
||||
<span style="font-weight: normal;"
|
||||
t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/>
|
||||
</div>
|
||||
|
||||
<hr class="heavy"/>
|
||||
|
||||
<!-- ===== STEPS WALK ===== -->
|
||||
<t t-foreach="all_steps" t-as="step">
|
||||
<!-- Aggregate captured input values from any
|
||||
move that touches this step (incoming or
|
||||
outgoing - the Record Inputs wizard
|
||||
creates a self-loop move with from=to=step). -->
|
||||
<t t-set="step_moves"
|
||||
t-value="job.move_ids.filtered(
|
||||
lambda m: m.from_step_id == step or m.to_step_id == step
|
||||
).sorted('move_datetime')"/>
|
||||
<t t-set="step_values"
|
||||
t-value="step_moves.mapped('transition_input_value_ids')"/>
|
||||
<!-- Pick a representative "Moved By" / Time:
|
||||
prefer the step's own date_finished, fall
|
||||
back to first move on the step, fall back
|
||||
to date_started. Same for the user. -->
|
||||
<t t-set="display_dt"
|
||||
t-value="step.date_finished or (step_moves and step_moves[-1].move_datetime) or step.date_started or False"/>
|
||||
<t t-set="display_user"
|
||||
t-value="(step.finished_by_user_id and step.finished_by_user_id.name)
|
||||
or (step_moves and step_moves[-1].moved_by_user_id and step_moves[-1].moved_by_user_id.name)
|
||||
or (step.started_by_user_id and step.started_by_user_id.name)
|
||||
or ''"/>
|
||||
|
||||
<div class="fp-step-block">
|
||||
<h3>
|
||||
<span t-esc="step.name or '-'"/>
|
||||
<t t-if="step.tank_id and step.tank_id.code">
|
||||
(<span t-esc="step.tank_id.code"/>)
|
||||
</t>
|
||||
<t t-if="step.state == 'skipped'">
|
||||
<span style="font-size: 9pt; color: #888; font-weight: normal;">- SKIPPED</span>
|
||||
</t>
|
||||
</h3>
|
||||
<div class="fp-meta">
|
||||
<strong>Part Number:</strong>
|
||||
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
|
||||
<span t-esc="job.part_catalog_id.part_number or ''"/>
|
||||
<t t-if="job.part_catalog_id.name">
|
||||
<span> </span><span t-esc="job.part_catalog_id.name"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
|
||||
</t>
|
||||
<t t-if="display_user or display_dt">
|
||||
<br/>
|
||||
<strong>Moved By:</strong>
|
||||
<span t-esc="display_user or '-'"/>
|
||||
<span> </span>
|
||||
<strong>Time:</strong>
|
||||
<span t-esc="job.fp_format_local(display_dt, '%b %d, %Y %I:%M:%S %p') or '-'"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Captured inputs table - only rendered
|
||||
when this step has at least one
|
||||
value recorded across all its moves. -->
|
||||
<t t-if="step_values">
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 24%;">Name</th>
|
||||
<th style="width: 32%;">Description</th>
|
||||
<th style="width: 18%;">Value</th>
|
||||
<th style="width: 26%;">Recorded By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="step_values" t-as="cv">
|
||||
<t t-set="inp" t-value="cv.node_input_id"/>
|
||||
<t t-set="prompt_name"
|
||||
t-value="(inp and inp.name) or (cv.value_text and cv.value_text.split(':')[0]) or 'Measurement'"/>
|
||||
<t t-set="prompt_hint"
|
||||
t-value="(inp and 'hint' in inp._fields and inp.hint) or ''"/>
|
||||
<t t-set="actual_str" t-value="''"/>
|
||||
<t t-if="cv.value_text">
|
||||
<t t-set="actual_str" t-value="cv.value_text"/>
|
||||
<!-- Strip the leading "Prompt:" prefix that
|
||||
ad-hoc rows store so the Value cell
|
||||
shows just the value, not the prompt
|
||||
twice. -->
|
||||
<t t-if="inp and inp.name and actual_str.startswith(inp.name + ':')">
|
||||
<t t-set="actual_str" t-value="actual_str[len(inp.name)+1:].strip()"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="cv.value_number">
|
||||
<t t-set="_unit" t-value="(inp and 'target_unit' in inp._fields and inp.target_unit) or ''"/>
|
||||
<t t-set="actual_str" t-value="('%s %s' % (cv.value_number, _unit)).strip()"/>
|
||||
</t>
|
||||
<t t-elif="cv.value_boolean is not False">
|
||||
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
|
||||
</t>
|
||||
<t t-elif="cv.value_date">
|
||||
<t t-set="actual_str"
|
||||
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
|
||||
</t>
|
||||
<!-- Signature-type prompts: show the
|
||||
recorder's Plating Signature image in
|
||||
the Value cell when available, with
|
||||
typed initials as caption beneath.
|
||||
Falls back to plain initials when the
|
||||
user hasn't uploaded a signature yet. -->
|
||||
<t t-set="is_sig_prompt"
|
||||
t-value="inp and 'input_type' in inp._fields and inp.input_type == 'signature'"/>
|
||||
<t t-set="sig_recorder" t-value="cv.move_id.moved_by_user_id"/>
|
||||
<t t-set="sig_img"
|
||||
t-value="(is_sig_prompt and sig_recorder and 'x_fc_signature_image' in sig_recorder._fields and sig_recorder.x_fc_signature_image) or False"/>
|
||||
<tr>
|
||||
<td><span t-esc="prompt_name"/></td>
|
||||
<td>
|
||||
<t t-if="prompt_hint">
|
||||
<span t-esc="prompt_hint"/>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="sig_img">
|
||||
<img class="fp-sig-inline"
|
||||
t-att-src="'data:image/png;base64,%s' % sig_img.decode()"
|
||||
t-att-alt="actual_str"/>
|
||||
<t t-if="actual_str">
|
||||
<br/>
|
||||
<span style="font-size: 7.5pt; color: #555;" t-esc="actual_str"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<strong t-esc="actual_str"/>
|
||||
</t>
|
||||
<!-- Photo cross-reference. Operators
|
||||
attached a photo for this prompt;
|
||||
point the reader to the gallery
|
||||
at the end of the doc. -->
|
||||
<t t-if="cv.value_attachment_id">
|
||||
<t t-set="_pidx" t-value="photo_index_by_id.get(cv.id)"/>
|
||||
<br t-if="actual_str or sig_img"/>
|
||||
<span class="fp-photo-ref">
|
||||
<i class="fa fa-camera"/>
|
||||
See Photo #<span t-esc="_pidx"/>
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="not all_steps">
|
||||
<p style="color: #888; font-style: italic;">
|
||||
No steps on this job yet - operators progress the
|
||||
job via Start / Finish & Next on the form, or
|
||||
via the tablet.
|
||||
</p>
|
||||
</t>
|
||||
|
||||
<!-- ===== PHOTO EVIDENCE GALLERY ===== -->
|
||||
<!-- Renders every photo-type captured value as a
|
||||
bordered tile with title (prompt + step) and
|
||||
description (operator + timestamp + any
|
||||
text caption they typed alongside the photo).
|
||||
Numbered to match the "See Photo #N" inline
|
||||
references above. Forced to its own page so
|
||||
the tiles don't get split mid-step. -->
|
||||
<t t-if="all_photo_values">
|
||||
<div style="page-break-before: always;"/>
|
||||
<div style="height: 6mm;"/>
|
||||
<div class="fp-photo-section">
|
||||
<h2>Photo Evidence (<span t-esc="len(all_photo_values)"/>)</h2>
|
||||
<div class="fp-photo-grid">
|
||||
<t t-foreach="all_photo_values" t-as="pv">
|
||||
<t t-set="pidx" t-value="photo_index_by_id.get(pv.id)"/>
|
||||
<t t-set="att" t-value="pv.value_attachment_id"/>
|
||||
<t t-set="ptitle"
|
||||
t-value="(pv.node_input_id and pv.node_input_id.name) or (pv.value_text and pv.value_text.split(':')[0]) or att.name or 'Photo'"/>
|
||||
<t t-set="pstep"
|
||||
t-value="(pv.move_id and ((pv.move_id.to_step_id and pv.move_id.to_step_id.name) or (pv.move_id.from_step_id and pv.move_id.from_step_id.name))) or ''"/>
|
||||
<t t-set="puser"
|
||||
t-value="(pv.move_id and pv.move_id.moved_by_user_id and pv.move_id.moved_by_user_id.name) or ''"/>
|
||||
<t t-set="pdt"
|
||||
t-value="pv.move_id and pv.move_id.move_datetime"/>
|
||||
<!-- Caption: strip the leading "Prompt:"
|
||||
prefix that ad-hoc rows store so we
|
||||
don't print the prompt name twice. -->
|
||||
<t t-set="pcaption" t-value="pv.value_text or ''"/>
|
||||
<t t-if="pv.node_input_id and pv.node_input_id.name and pcaption.startswith(pv.node_input_id.name + ':')">
|
||||
<t t-set="pcaption" t-value="pcaption[len(pv.node_input_id.name)+1:].strip()"/>
|
||||
</t>
|
||||
<div class="fp-photo-tile">
|
||||
<div class="fp-photo-imgwrap">
|
||||
<img t-att-src="'/web/image/%s' % att.id"
|
||||
t-att-alt="att.name"/>
|
||||
</div>
|
||||
<div class="fp-photo-title">
|
||||
Photo #<span t-esc="pidx"/> - <span t-esc="ptitle"/>
|
||||
</div>
|
||||
<div class="fp-photo-desc">
|
||||
<t t-if="pstep">
|
||||
<strong>Step:</strong> <span t-esc="pstep"/><br/>
|
||||
</t>
|
||||
<t t-if="puser or pdt">
|
||||
<strong>Captured by:</strong>
|
||||
<span t-esc="puser or '-'"/>
|
||||
<t t-if="pdt">
|
||||
on <span t-esc="job.fp_format_local(pdt, '%b %d, %Y %I:%M %p')"/>
|
||||
</t>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-if="pcaption">
|
||||
<strong>Note:</strong> <span t-esc="pcaption"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
|
||||
<!-- page-break-before is honoured by wkhtmltopdf
|
||||
but the new page starts flush against the
|
||||
header_spacing band; the spacer div below
|
||||
gives the cert table breathing room so it
|
||||
doesn't sit under the company logo. -->
|
||||
<div style="page-break-before: always;"/>
|
||||
<div style="height: 8mm;"/>
|
||||
|
||||
<!-- Certifier = the company's QA Manager, set in
|
||||
Settings → Fusion Plating → Contract Review.
|
||||
Falls back to the job's plating manager, then
|
||||
the company owner, then the settings signature
|
||||
override. Pulls the certifier's Plating
|
||||
Signature (`x_fc_signature_image`) from
|
||||
Preferences → My Profile.
|
||||
Resolution priority added 2026-05-17 per
|
||||
ops request - was auto-defaulting to the
|
||||
current user (whoever the job manager
|
||||
happened to be) which signed every cert as
|
||||
the wrong person. -->
|
||||
<t t-set="_qa_managers" t-value="('x_fc_qa_manager_user_ids' in company._fields and company.x_fc_qa_manager_user_ids) or False"/>
|
||||
<t t-set="certifier_user" t-value="(_qa_managers and _qa_managers[:1])
|
||||
or job.manager_id
|
||||
or (('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id) or False)"/>
|
||||
<t t-set="signature_img" t-value="False"/>
|
||||
<t t-if="certifier_user and 'x_fc_signature_image' in certifier_user._fields and certifier_user.x_fc_signature_image">
|
||||
<t t-set="signature_img" t-value="certifier_user.x_fc_signature_image"/>
|
||||
</t>
|
||||
<!-- Signature Override Image fallback retired
|
||||
2026-05-17. If no certifier user has uploaded
|
||||
their Plating Signature, the cert prints
|
||||
without a signature image (Name still shows). -->
|
||||
<t t-set="signer_name" t-value="(certifier_user and certifier_user.name) or ''"/>
|
||||
|
||||
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>
|
||||
<t t-set="_co_stmt" t-value="('x_fc_default_cert_statement' in company._fields and company.x_fc_default_cert_statement) or False"/>
|
||||
<t t-set="cert_statement" t-value="_cust_stmt or _co_stmt or 'We certify that the parts listed above have been processed in accordance with the specifications referenced and that all required tests have been performed. Records on file at our facility per AS9100 / ISO 9001 retention policy.'"/>
|
||||
|
||||
<!-- External note auto-fills the Other Comments box so
|
||||
anything the manager typed on the job ("subbed
|
||||
out for fluoride dip", "customer pickup at 4pm")
|
||||
prints on the customer-facing cert. Manager can
|
||||
still scribble on the printed copy if nothing
|
||||
was typed. -->
|
||||
<t t-set="other_comments" t-value="('x_fc_external_note' in job._fields and job.x_fc_external_note) or ''"/>
|
||||
|
||||
<table class="bordered">
|
||||
<tr>
|
||||
<td style="width: 50%; vertical-align: top; height: 40mm;">
|
||||
<strong>Certified By:</strong><br/>
|
||||
<t t-if="signature_img">
|
||||
<img t-att-src="'data:image/png;base64,%s' % signature_img.decode()"
|
||||
style="max-height: 22mm; max-width: 70mm;"/>
|
||||
</t><br/>
|
||||
<strong>Name:</strong> <span t-esc="signer_name"/>
|
||||
</td>
|
||||
<td style="width: 50%; vertical-align: top;">
|
||||
<strong>Certification Statement:</strong>
|
||||
<span style="font-size: 8.5pt;">
|
||||
Ref. WO# <span t-esc="short_wo"/>
|
||||
</span>
|
||||
<p style="font-size: 8pt; margin-top: 4px; white-space: pre-wrap;"
|
||||
t-esc="cert_statement"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="height: 25mm;">
|
||||
<strong>Other Comments:</strong>
|
||||
<p t-if="other_comments"
|
||||
style="font-size: 9pt; margin-top: 4px; white-space: pre-wrap;"
|
||||
t-esc="other_comments"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,51 +0,0 @@
|
||||
# Native job migration scripts
|
||||
|
||||
## migrate_to_fp_jobs.py
|
||||
|
||||
Copies live `mrp.production` / `mrp.workorder` records into the native
|
||||
`fp.job` / `fp.job.step` model. Idempotent — safe to run multiple times.
|
||||
|
||||
### Usage
|
||||
|
||||
Run from the host (e.g. entech) using `odoo shell`:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin\" < /mnt/extra-addons/custom/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py'"
|
||||
```
|
||||
|
||||
Or interactively from `odoo shell` (Python `exec` builtin, not a shell call):
|
||||
|
||||
```python
|
||||
exec(open('/mnt/extra-addons/custom/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py').read())
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
1. For every `mrp.production` record, creates a parallel `fp.job` with the same name and fields. Skips MOs that already have a fp.job mirror (`fp.job.legacy_mrp_production_id == mo.id`).
|
||||
2. For every `mrp.workorder` record, creates a parallel `fp.job.step`. Skips already-migrated WOs.
|
||||
3. Migrates `mrp.workorder.time_ids` to `fp.job.step.timelog`.
|
||||
4. Rebinds cross-references on dependent models (batches, holds, certs, deliveries, portal jobs, racking inspections).
|
||||
5. Audit log written to `/tmp/fp_jobs_migration.log` and to a chatter post on each migrated job.
|
||||
|
||||
### Safety
|
||||
|
||||
- Idempotent. Re-running skips already-migrated records.
|
||||
- Read-only on legacy MO/WO records. Original data untouched.
|
||||
- Cross-reference rebinds add new x_fc_job_id / x_fc_step_id values without removing legacy production_id / workorder_id values. Both stay populated for the 2-week shadow period.
|
||||
- Wrap in a transaction (default for `odoo shell`); if anything fails, rollback.
|
||||
|
||||
### Pre-migration audit
|
||||
|
||||
Run `audit_pre_migration.py` first to see what's about to happen. The
|
||||
script uses Python's `exec` builtin to load the file inside the running
|
||||
shell session — no shell exec involved.
|
||||
|
||||
Reports counts of MO/WO/dependent records and any data-quality concerns
|
||||
(MOs with no recipe, WOs with no work centre, etc).
|
||||
|
||||
### Post-migration audit
|
||||
|
||||
Run `audit_post_migration.py` after to verify counts match.
|
||||
|
||||
Reports row counts on fp.job, fp.job.step, and confirms all dependent
|
||||
records have new x_fc_*_id values.
|
||||
@@ -1,8 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# This package holds standalone migration / audit scripts for the native
|
||||
# job model rollout. Scripts under this directory are NOT imported at
|
||||
# module load time - they are invoked manually from `odoo shell` by the
|
||||
# cutover engineer. See README.md in this directory for usage.
|
||||
@@ -1,170 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Post-migration audit. Verifies migration counts match expectations.
|
||||
# Read-only - does NOT modify data. Run from `odoo shell`.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger('fp_jobs_migration')
|
||||
|
||||
|
||||
def run(env):
|
||||
"""Compare row counts between source MRP tables and target fp.job
|
||||
/ fp.job.step tables, plus dependent-model x_fc_*_id linkage.
|
||||
"""
|
||||
cr = env.cr
|
||||
print('=== Post-migration audit ===')
|
||||
|
||||
cr.execute("SELECT COUNT(*) FROM fp_job")
|
||||
job_total = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_job WHERE legacy_mrp_production_id IS NOT NULL"
|
||||
)
|
||||
job_migrated = cr.fetchone()[0]
|
||||
cr.execute("SELECT COUNT(*) FROM mrp_production")
|
||||
mo_total = cr.fetchone()[0]
|
||||
print(
|
||||
'mrp.production: %d, fp.job: %d (migrated: %d)'
|
||||
% (mo_total, job_total, job_migrated)
|
||||
)
|
||||
if job_migrated < mo_total:
|
||||
print('WARNING: %d MOs not migrated' % (mo_total - job_migrated))
|
||||
|
||||
cr.execute("SELECT COUNT(*) FROM fp_job_step")
|
||||
step_total = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_job_step WHERE legacy_mrp_workorder_id IS NOT NULL"
|
||||
)
|
||||
step_migrated = cr.fetchone()[0]
|
||||
cr.execute("SELECT COUNT(*) FROM mrp_workorder")
|
||||
wo_total = cr.fetchone()[0]
|
||||
print(
|
||||
'mrp.workorder: %d, fp.job.step: %d (migrated: %d)'
|
||||
% (wo_total, step_total, step_migrated)
|
||||
)
|
||||
if step_migrated < wo_total:
|
||||
print('WARNING: %d WOs not migrated' % (wo_total - step_migrated))
|
||||
|
||||
# Cross-references - for each dependent model, show counts of records
|
||||
# with the LEGACY production_id set vs the NEW x_fc_job_id set. After
|
||||
# migration, the second column should match the first (we don't clear
|
||||
# production_id during shadow period).
|
||||
if 'fp.quality.hold' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_quality_hold WHERE production_id IS NOT NULL"
|
||||
)
|
||||
with_mo = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_quality_hold WHERE x_fc_job_id IS NOT NULL"
|
||||
)
|
||||
with_job = cr.fetchone()[0]
|
||||
print(
|
||||
'fp.quality.hold: with production_id=%d, with x_fc_job_id=%d'
|
||||
% (with_mo, with_job)
|
||||
)
|
||||
|
||||
if 'fusion.plating.quality.hold' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_quality_hold "
|
||||
"WHERE production_id IS NOT NULL"
|
||||
)
|
||||
with_mo = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_quality_hold "
|
||||
"WHERE x_fc_job_id IS NOT NULL"
|
||||
)
|
||||
with_job = cr.fetchone()[0]
|
||||
print(
|
||||
'fusion.plating.quality.hold: with production_id=%d, with x_fc_job_id=%d'
|
||||
% (with_mo, with_job)
|
||||
)
|
||||
|
||||
if 'fp.certificate' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_certificate WHERE production_id IS NOT NULL"
|
||||
)
|
||||
with_mo = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_certificate WHERE x_fc_job_id IS NOT NULL"
|
||||
)
|
||||
with_job = cr.fetchone()[0]
|
||||
print(
|
||||
'fp.certificate: with production_id=%d, with x_fc_job_id=%d'
|
||||
% (with_mo, with_job)
|
||||
)
|
||||
|
||||
if 'fp.thickness.reading' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_thickness_reading "
|
||||
"WHERE production_id IS NOT NULL"
|
||||
)
|
||||
with_mo = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_thickness_reading "
|
||||
"WHERE x_fc_job_id IS NOT NULL"
|
||||
)
|
||||
with_job = cr.fetchone()[0]
|
||||
print(
|
||||
'fp.thickness.reading: with production_id=%d, with x_fc_job_id=%d'
|
||||
% (with_mo, with_job)
|
||||
)
|
||||
|
||||
if 'fusion.plating.batch' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_batch "
|
||||
"WHERE workorder_id IS NOT NULL"
|
||||
)
|
||||
with_wo = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_batch "
|
||||
"WHERE x_fc_step_id IS NOT NULL"
|
||||
)
|
||||
with_step = cr.fetchone()[0]
|
||||
print(
|
||||
'fusion.plating.batch: with workorder_id=%d, with x_fc_step_id=%d'
|
||||
% (with_wo, with_step)
|
||||
)
|
||||
|
||||
if 'fp.racking.inspection' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_racking_inspection "
|
||||
"WHERE production_id IS NOT NULL"
|
||||
)
|
||||
with_mo = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_racking_inspection "
|
||||
"WHERE x_fc_job_id IS NOT NULL"
|
||||
)
|
||||
with_job = cr.fetchone()[0]
|
||||
print(
|
||||
'fp.racking.inspection: with production_id=%d, with x_fc_job_id=%d'
|
||||
% (with_mo, with_job)
|
||||
)
|
||||
|
||||
if 'fusion.plating.delivery' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_delivery "
|
||||
"WHERE job_ref IS NOT NULL"
|
||||
)
|
||||
with_ref = cr.fetchone()[0]
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_delivery "
|
||||
"WHERE x_fc_job_id IS NOT NULL"
|
||||
)
|
||||
with_job = cr.fetchone()[0]
|
||||
print(
|
||||
'fusion.plating.delivery: with job_ref=%d, with x_fc_job_id=%d'
|
||||
% (with_ref, with_job)
|
||||
)
|
||||
|
||||
print('=== End post-migration audit ===')
|
||||
|
||||
|
||||
try:
|
||||
run(env) # noqa: F821 - `env` is provided by odoo shell
|
||||
except NameError:
|
||||
print(
|
||||
'This script expects to run inside `odoo shell` where `env` is defined.'
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Pre-migration audit. Reports row counts and data-quality concerns
|
||||
# before running migrate_to_fp_jobs.py. Read-only - does NOT modify data.
|
||||
#
|
||||
# Run from `odoo shell` where `env` is in scope. See ./README.md.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger('fp_jobs_migration')
|
||||
|
||||
|
||||
def run(env):
|
||||
"""Print a snapshot of what migrate_to_fp_jobs.py would touch.
|
||||
|
||||
All queries are SELECT-only. Safe to run on production at any time.
|
||||
"""
|
||||
cr = env.cr
|
||||
print('=== Pre-migration audit ===')
|
||||
|
||||
# Core MRP counts
|
||||
cr.execute("SELECT COUNT(*) FROM mrp_production")
|
||||
mo_total = cr.fetchone()[0]
|
||||
print('mrp.production total:', mo_total)
|
||||
|
||||
cr.execute("SELECT state, COUNT(*) FROM mrp_production GROUP BY state ORDER BY 1")
|
||||
print('mrp.production by state:', cr.fetchall())
|
||||
|
||||
cr.execute("SELECT COUNT(*) FROM mrp_workorder")
|
||||
wo_total = cr.fetchone()[0]
|
||||
print('mrp.workorder total:', wo_total)
|
||||
|
||||
cr.execute("SELECT state, COUNT(*) FROM mrp_workorder GROUP BY state ORDER BY 1")
|
||||
print('mrp.workorder by state:', cr.fetchall())
|
||||
|
||||
# Already migrated?
|
||||
cr.execute("SELECT COUNT(*) FROM fp_job")
|
||||
job_total = cr.fetchone()[0]
|
||||
print('fp.job already exists:', job_total)
|
||||
|
||||
cr.execute("SELECT COUNT(*) FROM fp_job_step")
|
||||
step_total = cr.fetchone()[0]
|
||||
print('fp.job.step already exists:', step_total)
|
||||
|
||||
# Data quality
|
||||
if 'x_fc_recipe_id' in env['mrp.production']._fields:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM mrp_production WHERE x_fc_recipe_id IS NULL"
|
||||
)
|
||||
no_recipe = cr.fetchone()[0]
|
||||
print('MOs without x_fc_recipe_id:', no_recipe)
|
||||
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM mrp_workorder WHERE workcenter_id IS NULL"
|
||||
)
|
||||
no_wc = cr.fetchone()[0]
|
||||
print('WOs without workcenter_id:', no_wc)
|
||||
|
||||
# Dependent records - check by model registry (truthful even when
|
||||
# the schema names differ from defaults).
|
||||
if 'fp.quality.hold' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_quality_hold WHERE production_id IS NOT NULL"
|
||||
)
|
||||
print('fp.quality.hold rows with production_id:', cr.fetchone()[0])
|
||||
if 'fp.certificate' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_certificate WHERE production_id IS NOT NULL"
|
||||
)
|
||||
print('fp.certificate rows with production_id:', cr.fetchone()[0])
|
||||
if 'fp.thickness.reading' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_thickness_reading WHERE production_id IS NOT NULL"
|
||||
)
|
||||
print(
|
||||
'fp.thickness.reading rows with production_id:',
|
||||
cr.fetchone()[0],
|
||||
)
|
||||
if 'fusion.plating.batch' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_batch WHERE workorder_id IS NOT NULL"
|
||||
)
|
||||
print(
|
||||
'fusion.plating.batch rows with workorder_id:',
|
||||
cr.fetchone()[0],
|
||||
)
|
||||
if 'fusion.plating.portal.job' in env:
|
||||
cr.execute("SELECT COUNT(*) FROM fusion_plating_portal_job")
|
||||
print('fusion.plating.portal.job total:', cr.fetchone()[0])
|
||||
if 'fp.racking.inspection' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fp_racking_inspection WHERE production_id IS NOT NULL"
|
||||
)
|
||||
print(
|
||||
'fp.racking.inspection rows with production_id:',
|
||||
cr.fetchone()[0],
|
||||
)
|
||||
if 'fusion.plating.delivery' in env:
|
||||
cr.execute(
|
||||
"SELECT COUNT(*) FROM fusion_plating_delivery WHERE job_ref IS NOT NULL"
|
||||
)
|
||||
print(
|
||||
'fusion.plating.delivery rows with job_ref:',
|
||||
cr.fetchone()[0],
|
||||
)
|
||||
|
||||
print('=== End pre-migration audit ===')
|
||||
|
||||
|
||||
# Run when the script is exec'd from odoo shell (env is in scope).
|
||||
try:
|
||||
run(env) # noqa: F821 - `env` is provided by odoo shell
|
||||
except NameError:
|
||||
print('This script expects to run inside `odoo shell` where `env` is defined.')
|
||||
@@ -1,292 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# DESTRUCTIVE: deletes ALL fp.job, fp.job.step, fp.job.step.timelog,
|
||||
# mrp.production, mrp.workorder, sale.order, account.move (invoices),
|
||||
# account.payment, stock.picking, stock.move, fusion.plating.quote.request
|
||||
# records and their dependent data (deliveries, certs, thickness readings,
|
||||
# holds, portal jobs, racking inspections). Preserves masters (partners,
|
||||
# parts, recipes, coating configs, baths, tanks, work centres, users,
|
||||
# groups, settings).
|
||||
#
|
||||
# Use only on demo/dev environments. Take a Proxmox snapshot first.
|
||||
|
||||
def run(env):
|
||||
print('=== Cleanup starting ===')
|
||||
|
||||
# Walk dependents bottom-up so FK cascades don't bite us.
|
||||
# 1. Time logs (cascades on step delete, but be explicit)
|
||||
n = env['fp.job.step.timelog'].search_count([])
|
||||
env['fp.job.step.timelog'].sudo().search([]).unlink()
|
||||
print(' Deleted %d fp.job.step.timelog rows' % n)
|
||||
|
||||
# 2. fp.job.node.override (cascades on job delete)
|
||||
n = env['fp.job.node.override'].search_count([])
|
||||
env['fp.job.node.override'].sudo().search([]).unlink()
|
||||
print(' Deleted %d fp.job.node.override rows' % n)
|
||||
|
||||
# 3. Deliveries linked to jobs OR with job_ref set OR linked to a SO that
|
||||
# we will delete. Delete ALL deliveries - they're test data.
|
||||
if 'fusion.plating.delivery' in env:
|
||||
deliveries = env['fusion.plating.delivery'].sudo().search([])
|
||||
n = len(deliveries)
|
||||
deliveries.unlink()
|
||||
print(' Deleted %d fusion.plating.delivery rows' % n)
|
||||
|
||||
# 4. Certificates linked to jobs/MOs
|
||||
if 'fp.certificate' in env:
|
||||
certs = env['fp.certificate'].sudo().search([])
|
||||
n = len(certs)
|
||||
certs.unlink()
|
||||
print(' Deleted %d fp.certificate rows' % n)
|
||||
|
||||
# 5. Thickness readings
|
||||
if 'fp.thickness.reading' in env:
|
||||
tr = env['fp.thickness.reading'].sudo().search([])
|
||||
n = len(tr)
|
||||
tr.unlink()
|
||||
print(' Deleted %d fp.thickness.reading rows' % n)
|
||||
|
||||
# 6. Quality holds linked to jobs/MOs
|
||||
if 'fusion.plating.quality.hold' in env:
|
||||
holds = env['fusion.plating.quality.hold'].sudo().search([])
|
||||
n = len(holds)
|
||||
holds.unlink()
|
||||
print(' Deleted %d fusion.plating.quality.hold rows' % n)
|
||||
|
||||
# 7. Portal jobs (linked to jobs OR legacy production)
|
||||
if 'fusion.plating.portal.job' in env:
|
||||
portals = env['fusion.plating.portal.job'].sudo().search([])
|
||||
n = len(portals)
|
||||
portals.unlink()
|
||||
print(' Deleted %d fusion.plating.portal.job rows' % n)
|
||||
|
||||
# 8. Racking inspections - required FK to mrp.production, so delete
|
||||
# BEFORE we kill the productions.
|
||||
if 'fp.racking.inspection' in env:
|
||||
insps = env['fp.racking.inspection'].sudo().search([])
|
||||
n = len(insps)
|
||||
insps.unlink()
|
||||
print(' Deleted %d fp.racking.inspection rows' % n)
|
||||
|
||||
# 9. Receiving records (required FK to sale.order - delete before SOs)
|
||||
if 'fp.receiving' in env:
|
||||
recs = env['fp.receiving'].sudo().search([])
|
||||
n = len(recs)
|
||||
recs.unlink()
|
||||
print(' Deleted %d fp.receiving rows' % n)
|
||||
|
||||
# 10. fp.job.step (cascade-safe via job_id, but be explicit)
|
||||
n = env['fp.job.step'].search_count([])
|
||||
env['fp.job.step'].sudo().search([]).unlink()
|
||||
print(' Deleted %d fp.job.step rows' % n)
|
||||
|
||||
# 11. fp.job
|
||||
n = env['fp.job'].search_count([])
|
||||
env['fp.job'].sudo().search([]).unlink()
|
||||
print(' Deleted %d fp.job rows' % n)
|
||||
|
||||
# 12. mrp.workorder (legacy)
|
||||
n = env['mrp.workorder'].search_count([])
|
||||
env['mrp.workorder'].sudo().search([]).unlink()
|
||||
print(' Deleted %d mrp.workorder rows' % n)
|
||||
|
||||
# 13. mrp.production (legacy) - force state via SQL so unlink() bypasses
|
||||
# Odoo's _unlink_except_done guard (which forbids deleting done MOs)
|
||||
# and the action_cancel guard (which forbids cancelling done MOs).
|
||||
# Demo data only.
|
||||
n = env['mrp.production'].search_count([])
|
||||
if n:
|
||||
# 'cancel' state is the only state mrp.production._unlink_except_done
|
||||
# explicitly permits.
|
||||
env.cr.execute("UPDATE mrp_production SET state='cancel'")
|
||||
# Also clear stock moves' state so cascaded checks pass
|
||||
env.cr.execute(
|
||||
"UPDATE stock_move SET state='cancel' "
|
||||
"WHERE raw_material_production_id IN (SELECT id FROM mrp_production) "
|
||||
"OR production_id IN (SELECT id FROM mrp_production)"
|
||||
)
|
||||
env.invalidate_all()
|
||||
env['mrp.production'].sudo().search([]).unlink()
|
||||
print(' Deleted %d mrp.production rows' % n)
|
||||
|
||||
# 14. Account payments (must come before invoices - payment is reconciled
|
||||
# against move lines)
|
||||
Payment = env['account.payment'].sudo()
|
||||
payments = Payment.search([])
|
||||
n = len(payments)
|
||||
if payments:
|
||||
for p in payments:
|
||||
if p.state == 'paid':
|
||||
try:
|
||||
p.action_draft()
|
||||
except Exception:
|
||||
env.cr.execute(
|
||||
"UPDATE account_payment SET state='draft' WHERE id=%s",
|
||||
(p.id,),
|
||||
)
|
||||
try:
|
||||
p.action_cancel()
|
||||
except Exception:
|
||||
pass
|
||||
# Clear reconciliation links pointing at the payment moves
|
||||
env.cr.execute(
|
||||
"DELETE FROM account_partial_reconcile "
|
||||
"WHERE debit_move_id IN (SELECT id FROM account_move_line WHERE move_id IN ("
|
||||
" SELECT move_id FROM account_payment WHERE id = ANY(%s))) "
|
||||
"OR credit_move_id IN (SELECT id FROM account_move_line WHERE move_id IN ("
|
||||
" SELECT move_id FROM account_payment WHERE id = ANY(%s)))",
|
||||
(payments.ids, payments.ids),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM account_payment WHERE id = ANY(%s)",
|
||||
(payments.ids,),
|
||||
)
|
||||
print(' Deleted %d account.payment rows' % n)
|
||||
|
||||
# 15. Invoices (account.move with out_invoice / out_refund / in_invoice
|
||||
# / in_refund move types). Posted ones must be drafted/cancelled first.
|
||||
Move = env['account.move'].sudo()
|
||||
invoices = Move.search([
|
||||
('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')),
|
||||
])
|
||||
n = len(invoices)
|
||||
if invoices:
|
||||
for inv in invoices:
|
||||
if inv.state == 'posted':
|
||||
try:
|
||||
inv.button_draft()
|
||||
except Exception:
|
||||
env.cr.execute(
|
||||
"UPDATE account_move SET state='draft' WHERE id=%s",
|
||||
(inv.id,),
|
||||
)
|
||||
try:
|
||||
inv.button_cancel()
|
||||
except Exception:
|
||||
env.cr.execute(
|
||||
"UPDATE account_move SET state='cancel' WHERE id=%s",
|
||||
(inv.id,),
|
||||
)
|
||||
env.invalidate_all()
|
||||
# Force-clear reconciliation links so unlink doesn't trip on
|
||||
# partial_reconcile_id
|
||||
env.cr.execute(
|
||||
"DELETE FROM account_partial_reconcile "
|
||||
"WHERE debit_move_id IN (SELECT id FROM account_move_line WHERE move_id = ANY(%s)) "
|
||||
"OR credit_move_id IN (SELECT id FROM account_move_line WHERE move_id = ANY(%s))",
|
||||
(invoices.ids, invoices.ids),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM account_move_line WHERE move_id = ANY(%s)",
|
||||
(invoices.ids,),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM account_move WHERE id = ANY(%s)",
|
||||
(invoices.ids,),
|
||||
)
|
||||
print(' Deleted %d account.move (invoice) rows' % n)
|
||||
|
||||
# 16. Stock pickings + moves (any leftovers from MOs / SOs)
|
||||
pickings = env['stock.picking'].sudo().search([])
|
||||
n = len(pickings)
|
||||
if pickings:
|
||||
for pk in pickings:
|
||||
if pk.state not in ('cancel', 'draft'):
|
||||
try:
|
||||
pk.action_cancel()
|
||||
except Exception:
|
||||
pass
|
||||
env.cr.execute(
|
||||
"UPDATE stock_picking SET state='cancel' WHERE id = ANY(%s)",
|
||||
(pickings.ids,),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM stock_move_line WHERE picking_id = ANY(%s)",
|
||||
(pickings.ids,),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM stock_move WHERE picking_id = ANY(%s)",
|
||||
(pickings.ids,),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM stock_picking WHERE id = ANY(%s)",
|
||||
(pickings.ids,),
|
||||
)
|
||||
print(' Deleted %d stock.picking rows' % n)
|
||||
|
||||
# Any remaining orphan stock.move rows
|
||||
moves = env['stock.move'].sudo().search([])
|
||||
n = len(moves)
|
||||
if moves:
|
||||
env.cr.execute(
|
||||
"DELETE FROM stock_move_line WHERE move_id = ANY(%s)",
|
||||
(moves.ids,),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM stock_move WHERE id = ANY(%s)",
|
||||
(moves.ids,),
|
||||
)
|
||||
print(' Deleted %d stock.move rows' % n)
|
||||
|
||||
# 17. Sale orders (cancel any non-cancel state first). Delete ALL -
|
||||
# demo data only.
|
||||
sos = env['sale.order'].sudo().search([])
|
||||
n = len(sos)
|
||||
if sos:
|
||||
for so in sos:
|
||||
if so.state not in ('cancel', 'draft'):
|
||||
try:
|
||||
so.action_cancel()
|
||||
except Exception:
|
||||
env.cr.execute(
|
||||
"UPDATE sale_order SET state='cancel' WHERE id=%s",
|
||||
(so.id,),
|
||||
)
|
||||
env.invalidate_all()
|
||||
# Drop SO lines explicitly to avoid FK trip on unlink
|
||||
env.cr.execute(
|
||||
"DELETE FROM sale_order_line WHERE order_id = ANY(%s)",
|
||||
(sos.ids,),
|
||||
)
|
||||
env.cr.execute(
|
||||
"DELETE FROM sale_order WHERE id = ANY(%s)",
|
||||
(sos.ids,),
|
||||
)
|
||||
print(' Deleted %d sale.order rows' % n)
|
||||
|
||||
# 18. Quote requests
|
||||
if 'fusion.plating.quote.request' in env:
|
||||
qrs = env['fusion.plating.quote.request'].sudo().search([])
|
||||
n = len(qrs)
|
||||
if qrs:
|
||||
try:
|
||||
qrs.unlink()
|
||||
except Exception:
|
||||
env.cr.execute(
|
||||
"DELETE FROM fusion_plating_quote_request WHERE id = ANY(%s)",
|
||||
(qrs.ids,),
|
||||
)
|
||||
print(' Deleted %d fusion.plating.quote.request rows' % n)
|
||||
|
||||
# 19. Reset sequences for SO and invoices so new ones start fresh
|
||||
for code in ('sale.order', 'account.move.invoice'):
|
||||
seq = env['ir.sequence'].sudo().search([('code', '=', code)], limit=1)
|
||||
if seq:
|
||||
seq.number_next = 1
|
||||
|
||||
# 20. Reset fp.job sequence so new ones start from JOB/00001
|
||||
seq = env['ir.sequence'].sudo().search([('code', '=', 'fp.job')], limit=1)
|
||||
if seq:
|
||||
seq.number_next = 1
|
||||
print(' Reset fp.job sequence to start at 1')
|
||||
|
||||
env.cr.commit()
|
||||
print('=== Cleanup complete ===')
|
||||
|
||||
|
||||
try:
|
||||
run(env)
|
||||
except NameError:
|
||||
print('Run inside `odoo shell`.')
|
||||
@@ -1,487 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Native job migration: copies mrp.production / mrp.workorder records
|
||||
# into fp.job / fp.job.step. Idempotent. Run from `odoo shell`.
|
||||
#
|
||||
# Strategy:
|
||||
# 1. Verify the legacy_mrp_production_id / legacy_mrp_workorder_id
|
||||
# idempotency-key fields exist on fp.job / fp.job.step. If missing,
|
||||
# bail (the user must upgrade fusion_plating_jobs first).
|
||||
# 2. For each MO: skip if already mirrored; else create fp.job with
|
||||
# same name, partner, qty, dates, state, etc.
|
||||
# 3. For each WO under MO: skip if already mirrored; else create
|
||||
# fp.job.step with same name, work centre (mapped via legacy
|
||||
# code), sequence, durations, state.
|
||||
# 4. Time logs: copy mrp.workorder.time_ids if available.
|
||||
# 5. Rebind cross-references on dependent models (defensive - only
|
||||
# writes a value when the field exists on both sides AND the
|
||||
# target field is currently empty).
|
||||
# 6. Write audit log to /tmp/fp_jobs_migration.log.
|
||||
#
|
||||
# This is NOT an Odoo upgrade hook - it is an explicit cutover step.
|
||||
# Run from `odoo shell -d <db>` so the surrounding transaction can be
|
||||
# rolled back manually if the operator spots a problem (`env.cr.rollback()`).
|
||||
# At the end of run() we env.cr.commit() - the operator can comment that
|
||||
# out if they want to inspect changes before persisting.
|
||||
#
|
||||
# See ./README.md for usage.
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger('fp_jobs_migration')
|
||||
|
||||
|
||||
# Map of mrp.production.state -> fp.job.state.
|
||||
# fp.job.state values are defined in fusion_plating core (Phase 1 spec).
|
||||
JOB_STATE_MAP = {
|
||||
'draft': 'draft',
|
||||
'confirmed': 'confirmed',
|
||||
'progress': 'in_progress',
|
||||
'to_close': 'in_progress',
|
||||
'done': 'done',
|
||||
'cancel': 'cancelled',
|
||||
}
|
||||
|
||||
# Map of mrp.workorder.state -> fp.job.step.state
|
||||
STEP_STATE_MAP = {
|
||||
'pending': 'pending',
|
||||
'waiting': 'pending',
|
||||
'ready': 'ready',
|
||||
'progress': 'in_progress',
|
||||
'done': 'done',
|
||||
'cancel': 'cancelled',
|
||||
}
|
||||
|
||||
|
||||
def map_work_centre(env, mrp_wc):
|
||||
"""Find the fp.work.centre that corresponds to a mrp.workcenter.
|
||||
|
||||
Strategy: match by code. If no match, return False (the step will
|
||||
have no work centre - operator can fix manually post-cutover).
|
||||
"""
|
||||
if not mrp_wc:
|
||||
return False
|
||||
if not mrp_wc.code:
|
||||
return False
|
||||
fp_wc = env['fp.work.centre'].search(
|
||||
[('code', '=', mrp_wc.code)], limit=1,
|
||||
)
|
||||
return fp_wc.id if fp_wc else False
|
||||
|
||||
|
||||
def _resolve_partner(env, mo):
|
||||
"""Best-effort partner lookup for the MO.
|
||||
|
||||
Order of preference:
|
||||
1. mo.x_fc_customer_id (some custom modules add this)
|
||||
2. partner from sale.order matching mo.origin
|
||||
3. mo.picking_type_id.warehouse_id.partner_id (warehouse address)
|
||||
4. The company partner (last-resort placeholder for orphan MOs)
|
||||
"""
|
||||
if 'x_fc_customer_id' in mo._fields and mo.x_fc_customer_id:
|
||||
return mo.x_fc_customer_id.id
|
||||
if mo.origin:
|
||||
so = env['sale.order'].search([('name', '=', mo.origin)], limit=1)
|
||||
if so:
|
||||
return so.partner_id.id
|
||||
# Warehouse partner fallback (works for internal/transfer MOs)
|
||||
if 'picking_type_id' in mo._fields and mo.picking_type_id:
|
||||
wh = mo.picking_type_id.warehouse_id
|
||||
if wh and wh.partner_id:
|
||||
return wh.partner_id.id
|
||||
# Last resort: company partner. This is a placeholder for orphan
|
||||
# demo/legacy MOs that have no SO link and no warehouse partner.
|
||||
# Audit log will flag these so they can be reassigned manually.
|
||||
return mo.company_id.partner_id.id if mo.company_id else env.company.partner_id.id
|
||||
|
||||
|
||||
def migrate_mo(env, mo, audit):
|
||||
"""Migrate one mrp.production -> fp.job. Idempotent."""
|
||||
Job = env['fp.job']
|
||||
existing = Job.search(
|
||||
[('legacy_mrp_production_id', '=', mo.id)], limit=1,
|
||||
)
|
||||
if existing:
|
||||
audit['mo_skipped'] += 1
|
||||
return existing
|
||||
|
||||
vals = {
|
||||
'name': mo.name, # preserve WH/MO/00033 format
|
||||
'partner_id': _resolve_partner(env, mo),
|
||||
'product_id': mo.product_id.id if mo.product_id else False,
|
||||
'qty': mo.product_qty,
|
||||
'date_deadline': mo.date_deadline,
|
||||
'date_planned_start': mo.date_start,
|
||||
'date_finished': mo.date_finished,
|
||||
'origin': mo.origin,
|
||||
'state': JOB_STATE_MAP.get(mo.state, 'draft'),
|
||||
'legacy_mrp_production_id': mo.id,
|
||||
}
|
||||
# Optional fields - only set when the source has them
|
||||
if 'x_fc_facility_id' in mo._fields and mo.x_fc_facility_id:
|
||||
if 'facility_id' in Job._fields:
|
||||
vals['facility_id'] = mo.x_fc_facility_id.id
|
||||
if 'x_fc_manager_id' in mo._fields and mo.x_fc_manager_id:
|
||||
if 'manager_id' in Job._fields:
|
||||
vals['manager_id'] = mo.x_fc_manager_id.id
|
||||
if 'x_fc_recipe_id' in mo._fields and mo.x_fc_recipe_id:
|
||||
if 'recipe_id' in Job._fields:
|
||||
vals['recipe_id'] = mo.x_fc_recipe_id.id
|
||||
if 'x_fc_portal_job_id' in mo._fields and mo.x_fc_portal_job_id:
|
||||
if 'portal_job_id' in Job._fields:
|
||||
vals['portal_job_id'] = mo.x_fc_portal_job_id.id
|
||||
if 'x_fc_part_catalog_id' in mo._fields and mo.x_fc_part_catalog_id:
|
||||
if 'part_catalog_id' in Job._fields:
|
||||
vals['part_catalog_id'] = mo.x_fc_part_catalog_id.id
|
||||
if 'x_fc_coating_config_id' in mo._fields and mo.x_fc_coating_config_id:
|
||||
if 'coating_config_id' in Job._fields:
|
||||
vals['coating_config_id'] = mo.x_fc_coating_config_id.id
|
||||
|
||||
# Bypass any auto-create lifecycle hooks while migrating - the source
|
||||
# MO already had its hooks run when it was originally created. We
|
||||
# don't want a second portal job / racking inspection / etc.
|
||||
job = Job.with_context(
|
||||
fp_jobs_migration=True,
|
||||
tracking_disable=True,
|
||||
mail_create_nosubscribe=True,
|
||||
mail_create_nolog=True,
|
||||
).create(vals)
|
||||
audit['mo_migrated'] += 1
|
||||
audit['jobs_created'].append(job.id)
|
||||
return job
|
||||
|
||||
|
||||
def migrate_wo(env, wo, job, audit):
|
||||
"""Migrate one mrp.workorder -> fp.job.step. Idempotent."""
|
||||
Step = env['fp.job.step']
|
||||
existing = Step.search(
|
||||
[('legacy_mrp_workorder_id', '=', wo.id)], limit=1,
|
||||
)
|
||||
if existing:
|
||||
audit['wo_skipped'] += 1
|
||||
return existing
|
||||
|
||||
wc_id = map_work_centre(env, wo.workcenter_id)
|
||||
vals = {
|
||||
'job_id': job.id,
|
||||
'name': wo.name,
|
||||
'sequence': wo.sequence or 10,
|
||||
'state': STEP_STATE_MAP.get(wo.state, 'pending'),
|
||||
'work_centre_id': wc_id,
|
||||
'duration_expected': wo.duration_expected or 0.0,
|
||||
'duration_actual': wo.duration or 0.0,
|
||||
'date_started': wo.date_start,
|
||||
'date_finished': wo.date_finished,
|
||||
'legacy_mrp_workorder_id': wo.id,
|
||||
}
|
||||
if 'x_fc_recipe_node_id' in wo._fields and wo.x_fc_recipe_node_id:
|
||||
if 'recipe_node_id' in Step._fields:
|
||||
vals['recipe_node_id'] = wo.x_fc_recipe_node_id.id
|
||||
if 'x_fc_assigned_user_id' in wo._fields and wo.x_fc_assigned_user_id:
|
||||
if 'assigned_user_id' in Step._fields:
|
||||
vals['assigned_user_id'] = wo.x_fc_assigned_user_id.id
|
||||
if 'x_fc_thickness_target' in wo._fields and wo.x_fc_thickness_target:
|
||||
if 'thickness_target' in Step._fields:
|
||||
vals['thickness_target'] = wo.x_fc_thickness_target
|
||||
if 'x_fc_dwell_time_minutes' in wo._fields and wo.x_fc_dwell_time_minutes:
|
||||
if 'dwell_time_minutes' in Step._fields:
|
||||
vals['dwell_time_minutes'] = wo.x_fc_dwell_time_minutes
|
||||
|
||||
step = Step.with_context(
|
||||
fp_jobs_migration=True,
|
||||
tracking_disable=True,
|
||||
).create(vals)
|
||||
audit['wo_migrated'] += 1
|
||||
|
||||
# Migrate time logs - only if both sides have a time-log model
|
||||
if 'time_ids' in wo._fields and wo.time_ids \
|
||||
and 'fp.job.step.timelog' in env:
|
||||
TimeLog = env['fp.job.step.timelog']
|
||||
for tl in wo.time_ids:
|
||||
try:
|
||||
TimeLog.create({
|
||||
'step_id': step.id,
|
||||
'user_id': tl.user_id.id if tl.user_id else env.user.id,
|
||||
'date_started': tl.date_start,
|
||||
'date_finished': tl.date_end,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Failed to migrate time log %s on WO %s: %s',
|
||||
tl.id, wo.name, e,
|
||||
)
|
||||
|
||||
return step
|
||||
|
||||
|
||||
def _safe_set(record, fname, value):
|
||||
"""Set a field only when (a) the field exists and (b) is currently empty.
|
||||
|
||||
Returns True if a write happened, False otherwise. Catches exceptions
|
||||
individually so one bad record doesn't sink the whole batch.
|
||||
"""
|
||||
if fname not in record._fields:
|
||||
return False
|
||||
current = record[fname]
|
||||
# Many2one .id is 0 / False when empty; Char/Text empty string also OK
|
||||
if current:
|
||||
return False
|
||||
try:
|
||||
record[fname] = value
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Failed to set %s.%s on id=%s: %s',
|
||||
record._name, fname, record.id, e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def rebind_dependents(env, mo, job, audit):
|
||||
"""Update cross-references on dependent models.
|
||||
|
||||
Only writes when:
|
||||
- the target model is registered in env
|
||||
- the target field exists on the model
|
||||
- the target field is currently empty (idempotent)
|
||||
Legacy production_id / workorder_id values are LEFT INTACT so the
|
||||
shadow period can read both old and new linkages.
|
||||
"""
|
||||
# Build a step lookup by legacy WO id (used for batches and any other
|
||||
# WO-scoped dependents).
|
||||
step_by_wo = {}
|
||||
if mo.workorder_ids:
|
||||
Step = env['fp.job.step']
|
||||
steps = Step.search([
|
||||
('legacy_mrp_workorder_id', 'in', mo.workorder_ids.ids),
|
||||
])
|
||||
for s in steps:
|
||||
step_by_wo[s.legacy_mrp_workorder_id] = s
|
||||
|
||||
# ---- fusion.plating.batch (workorder_id → x_fc_step_id) ----
|
||||
if 'fusion.plating.batch' in env:
|
||||
Batch = env['fusion.plating.batch']
|
||||
for wo in mo.workorder_ids:
|
||||
step = step_by_wo.get(wo.id)
|
||||
if not step:
|
||||
continue
|
||||
batches = Batch.search([('workorder_id', '=', wo.id)])
|
||||
for batch in batches:
|
||||
if _safe_set(batch, 'x_fc_step_id', step.id):
|
||||
audit['batches_rebound'] += 1
|
||||
# batch may also have x_fc_job_id (the job-level link)
|
||||
_safe_set(batch, 'x_fc_job_id', job.id)
|
||||
|
||||
# ---- fp.quality.hold (production_id → x_fc_job_id) ----
|
||||
if 'fp.quality.hold' in env:
|
||||
Hold = env['fp.quality.hold']
|
||||
if 'production_id' in Hold._fields:
|
||||
holds = Hold.search([('production_id', '=', mo.id)])
|
||||
for h in holds:
|
||||
if _safe_set(h, 'x_fc_job_id', job.id):
|
||||
audit['holds_rebound'] += 1
|
||||
# If the hold also has workorder_id, rebind to step
|
||||
if 'workorder_id' in Hold._fields and h.workorder_id:
|
||||
step = step_by_wo.get(h.workorder_id.id)
|
||||
if step:
|
||||
_safe_set(h, 'x_fc_step_id', step.id)
|
||||
|
||||
# ---- fusion.plating.quality.hold (legacy fallback name) ----
|
||||
if 'fusion.plating.quality.hold' in env:
|
||||
Hold2 = env['fusion.plating.quality.hold']
|
||||
if 'production_id' in Hold2._fields:
|
||||
holds = Hold2.search([('production_id', '=', mo.id)])
|
||||
for h in holds:
|
||||
if _safe_set(h, 'x_fc_job_id', job.id):
|
||||
audit['holds_rebound'] += 1
|
||||
if 'workorder_id' in Hold2._fields and h.workorder_id:
|
||||
step = step_by_wo.get(h.workorder_id.id)
|
||||
if step:
|
||||
_safe_set(h, 'x_fc_step_id', step.id)
|
||||
|
||||
# ---- fp.certificate (production_id → x_fc_job_id) ----
|
||||
if 'fp.certificate' in env:
|
||||
Cert = env['fp.certificate']
|
||||
if 'production_id' in Cert._fields:
|
||||
certs = Cert.search([('production_id', '=', mo.id)])
|
||||
for c in certs:
|
||||
if _safe_set(c, 'x_fc_job_id', job.id):
|
||||
audit['certs_rebound'] += 1
|
||||
|
||||
# ---- fp.thickness.reading (production_id → x_fc_job_id, optional step) ----
|
||||
if 'fp.thickness.reading' in env:
|
||||
TR = env['fp.thickness.reading']
|
||||
if 'production_id' in TR._fields:
|
||||
readings = TR.search([('production_id', '=', mo.id)])
|
||||
for r in readings:
|
||||
if _safe_set(r, 'x_fc_job_id', job.id):
|
||||
audit['readings_rebound'] += 1
|
||||
if 'workorder_id' in TR._fields and r.workorder_id:
|
||||
step = step_by_wo.get(r.workorder_id.id)
|
||||
if step:
|
||||
_safe_set(r, 'x_fc_step_id', step.id)
|
||||
|
||||
# ---- fusion.plating.portal.job (mo.x_fc_portal_job_id → x_fc_job_id) ----
|
||||
if 'fusion.plating.portal.job' in env \
|
||||
and 'x_fc_portal_job_id' in mo._fields \
|
||||
and mo.x_fc_portal_job_id:
|
||||
portal = mo.x_fc_portal_job_id
|
||||
if _safe_set(portal, 'x_fc_job_id', job.id):
|
||||
audit['portals_rebound'] += 1
|
||||
|
||||
# ---- fp.racking.inspection (production_id → x_fc_job_id) ----
|
||||
if 'fp.racking.inspection' in env:
|
||||
Insp = env['fp.racking.inspection']
|
||||
if 'production_id' in Insp._fields:
|
||||
insps = Insp.search([('production_id', '=', mo.id)])
|
||||
for i in insps:
|
||||
if _safe_set(i, 'x_fc_job_id', job.id):
|
||||
audit['inspections_rebound'] += 1
|
||||
|
||||
# ---- fusion.plating.delivery (job_ref Char → x_fc_job_id Many2one) ----
|
||||
if 'fusion.plating.delivery' in env:
|
||||
Delivery = env['fusion.plating.delivery']
|
||||
if 'job_ref' in Delivery._fields:
|
||||
deliveries = Delivery.search([('job_ref', '=', mo.name)])
|
||||
for d in deliveries:
|
||||
if _safe_set(d, 'x_fc_job_id', job.id):
|
||||
audit['deliveries_rebound'] += 1
|
||||
|
||||
|
||||
def run(env):
|
||||
"""Main entry point. Call as `run(env)` from `odoo shell`.
|
||||
|
||||
Returns the audit dict (also written to /tmp/fp_jobs_migration.log).
|
||||
Commits the transaction at the end. To dry-run, comment out
|
||||
`env.cr.commit()` below or pass `--no-http` and `env.cr.rollback()`
|
||||
after inspecting the result.
|
||||
"""
|
||||
audit = {
|
||||
'started_at': datetime.now().isoformat(),
|
||||
'mo_migrated': 0,
|
||||
'mo_skipped': 0,
|
||||
'wo_migrated': 0,
|
||||
'wo_skipped': 0,
|
||||
'batches_rebound': 0,
|
||||
'holds_rebound': 0,
|
||||
'certs_rebound': 0,
|
||||
'readings_rebound': 0,
|
||||
'portals_rebound': 0,
|
||||
'inspections_rebound': 0,
|
||||
'deliveries_rebound': 0,
|
||||
'errors': [],
|
||||
'jobs_created': [],
|
||||
}
|
||||
|
||||
# Verify the idempotency-key fields exist before doing anything.
|
||||
# If they're missing, the operator forgot to upgrade
|
||||
# fusion_plating_jobs to v19.0.2.0.0+ and we'd create duplicates on
|
||||
# every run.
|
||||
if 'legacy_mrp_production_id' not in env['fp.job']._fields:
|
||||
msg = (
|
||||
'fp.job.legacy_mrp_production_id field missing - upgrade '
|
||||
'fusion_plating_jobs to v19.0.2.0.0+ before running this '
|
||||
'script.'
|
||||
)
|
||||
print(msg)
|
||||
_logger.error(msg)
|
||||
return None
|
||||
if 'legacy_mrp_workorder_id' not in env['fp.job.step']._fields:
|
||||
msg = (
|
||||
'fp.job.step.legacy_mrp_workorder_id field missing - upgrade '
|
||||
'fusion_plating_jobs to v19.0.2.0.0+ before running this '
|
||||
'script.'
|
||||
)
|
||||
print(msg)
|
||||
_logger.error(msg)
|
||||
return None
|
||||
|
||||
print('=== Migration starting ===')
|
||||
# The fp_jobs_migration context flag tells fp.job.action_confirm and
|
||||
# fp.job.button_mark_done to skip lifecycle side-effects (creating
|
||||
# portal jobs, QC checks, racking inspections, deliveries, certs,
|
||||
# notifications). The migration script rebinds existing records via
|
||||
# x_fc_job_id directly - so the side-effects would create duplicates.
|
||||
env = env(context=dict(env.context, fp_jobs_migration=True))
|
||||
MO = env['mrp.production']
|
||||
all_mos = MO.search([])
|
||||
print('Migrating %d MOs and their WOs...' % len(all_mos))
|
||||
|
||||
for mo in all_mos:
|
||||
# Wrap each MO migration in a savepoint so a failure on one
|
||||
# MO doesn't abort the whole transaction (which would cascade
|
||||
# "current transaction is aborted" errors on every subsequent
|
||||
# MO and prevent any successful migration from committing).
|
||||
try:
|
||||
with env.cr.savepoint():
|
||||
job = migrate_mo(env, mo, audit)
|
||||
for wo in mo.workorder_ids:
|
||||
try:
|
||||
with env.cr.savepoint():
|
||||
migrate_wo(env, wo, job, audit)
|
||||
except Exception as e:
|
||||
audit['errors'].append({
|
||||
'wo': wo.id,
|
||||
'wo_name': wo.name,
|
||||
'mo': mo.id,
|
||||
'error': str(e),
|
||||
})
|
||||
_logger.error(
|
||||
'Migration failed for WO %s (MO %s): %s',
|
||||
wo.name, mo.name, e,
|
||||
)
|
||||
rebind_dependents(env, mo, job, audit)
|
||||
except Exception as e:
|
||||
audit['errors'].append({
|
||||
'mo': mo.id,
|
||||
'name': mo.name,
|
||||
'error': str(e),
|
||||
})
|
||||
_logger.error('Migration failed for MO %s: %s', mo.name, e)
|
||||
|
||||
audit['finished_at'] = datetime.now().isoformat()
|
||||
print('=== Migration finished ===')
|
||||
print('MOs migrated:', audit['mo_migrated'],
|
||||
'(skipped:', audit['mo_skipped'], ')')
|
||||
print('WOs migrated:', audit['wo_migrated'],
|
||||
'(skipped:', audit['wo_skipped'], ')')
|
||||
print('Batches rebound:', audit['batches_rebound'])
|
||||
print('Holds rebound:', audit['holds_rebound'])
|
||||
print('Certs rebound:', audit['certs_rebound'])
|
||||
print('Readings rebound:', audit['readings_rebound'])
|
||||
print('Portals rebound:', audit['portals_rebound'])
|
||||
print('Inspections rebound:', audit['inspections_rebound'])
|
||||
print('Deliveries rebound:', audit['deliveries_rebound'])
|
||||
print('Errors:', len(audit['errors']))
|
||||
|
||||
# Write audit log
|
||||
try:
|
||||
with open('/tmp/fp_jobs_migration.log', 'a') as f:
|
||||
f.write('\n=== Migration run at %s ===\n' % audit['started_at'])
|
||||
for k, v in audit.items():
|
||||
if k == 'jobs_created':
|
||||
f.write('%s: %d records\n' % (k, len(v)))
|
||||
elif k == 'errors':
|
||||
f.write('errors: %d\n' % len(v))
|
||||
for err in v:
|
||||
f.write(' %s\n' % err)
|
||||
else:
|
||||
f.write('%s: %s\n' % (k, v))
|
||||
except Exception as e:
|
||||
print('Could not write audit log:', e)
|
||||
|
||||
# Commit. Comment this out to dry-run.
|
||||
env.cr.commit()
|
||||
return audit
|
||||
|
||||
|
||||
# Run when exec'd from odoo shell
|
||||
try:
|
||||
result = run(env) # noqa: F821 - `env` is provided by odoo shell
|
||||
except NameError:
|
||||
print(
|
||||
'This script expects to run inside `odoo shell` where `env` is defined.'
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""End-to-end numbering walkthrough for the 2026-05-12 parent-number
|
||||
hierarchy. Quote -> confirm -> 2 invoices (partial billing) -> CoC ->
|
||||
delivery -> receiving -> NCR (legacy fallback) -> Hold (parent-derived)
|
||||
-> immutability check -> unlink block check -> direct invoice block.
|
||||
|
||||
Asserts every SO-linked doc shares the same parent number. Re-runnable;
|
||||
rolls back at the end so no DB state is left behind.
|
||||
|
||||
Run via odoo-shell:
|
||||
exec(open('/path/to/numbering_e2e_walkthrough.py').read())
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
SO = env['sale.order']
|
||||
AM = env['account.move']
|
||||
journal = env['account.journal'].search([('type', '=', 'sale')], limit=1)
|
||||
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=2)
|
||||
assert len(parts) >= 2, 'need at least 2 parts with default recipes'
|
||||
partner = env['res.partner'].search([], limit=1)
|
||||
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||
product = env['product.product'].search([('type', '!=', 'service')], limit=1) or env['product.product'].search([], limit=1)
|
||||
|
||||
print('=' * 60)
|
||||
print('Numbering hierarchy E2E walkthrough')
|
||||
print('=' * 60)
|
||||
|
||||
# === A: Quote -> confirm ===
|
||||
so = SO.create({
|
||||
'partner_id': partner.id,
|
||||
'x_fc_po_override': True,
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 5,
|
||||
'x_fc_part_catalog_id': parts[0].id, 'sequence': 10}),
|
||||
(0, 0, {'product_id': product.id, 'product_uom_qty': 3,
|
||||
'x_fc_part_catalog_id': parts[1].id, 'sequence': 20}),
|
||||
],
|
||||
})
|
||||
quote_name = so.name
|
||||
print(f'A. Quote: {quote_name}')
|
||||
assert quote_name.startswith('Q'), f'expected Q-prefix, got {quote_name}'
|
||||
|
||||
so.action_confirm()
|
||||
parent = so.x_fc_parent_number
|
||||
print(f'A. Confirmed: {so.name} (parent={parent}, quote_ref={so.x_fc_quote_ref})')
|
||||
assert so.name == f'SO-{parent}'
|
||||
assert so.x_fc_quote_ref == quote_name
|
||||
|
||||
# === B: WOs (2 recipes split SO into -01, -02) ===
|
||||
jobs = env['fp.job'].search([('sale_order_id', '=', so.id)], order='x_fc_doc_index')
|
||||
print(f'B. WOs: {jobs.mapped("name")}')
|
||||
assert len(jobs) == 2
|
||||
assert jobs[0].name == f'WO-{parent}-01'
|
||||
assert jobs[1].name == f'WO-{parent}-02'
|
||||
|
||||
# === C: Two invoices (partial billing) ===
|
||||
inv1 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({
|
||||
'move_type': 'out_invoice', 'partner_id': partner.id,
|
||||
'journal_id': journal.id, 'invoice_origin': so.name,
|
||||
})
|
||||
inv2 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({
|
||||
'move_type': 'out_invoice', 'partner_id': partner.id,
|
||||
'journal_id': journal.id, 'invoice_origin': so.name,
|
||||
})
|
||||
print(f'C. Invoices: {inv1.name}, {inv2.name}')
|
||||
assert inv1.name == f'IN-{parent}'
|
||||
assert inv2.name == f'IN-{parent}-02'
|
||||
|
||||
# === D: CoC ===
|
||||
coc = env['fp.certificate'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
||||
print(f'D. CoC: {coc.name}')
|
||||
assert coc.name == f'CoC-{parent}'
|
||||
|
||||
# === E: Delivery (linked via job_ref) ===
|
||||
dlv = env['fusion.plating.delivery'].create({'partner_id': partner.id, 'job_ref': jobs[0].name})
|
||||
print(f'E. Delivery: {dlv.name}')
|
||||
assert dlv.name == f'DLV-{parent}'
|
||||
|
||||
# === F: Receiving (already auto-created at confirm; manual is -02) ===
|
||||
existing_rcv = env['fp.receiving'].search([('sale_order_id', '=', so.id)])
|
||||
print(f'F. Receivings (incl. auto-created): {existing_rcv.mapped("name")}')
|
||||
assert any(r.name == f'RCV-{parent}' for r in existing_rcv)
|
||||
|
||||
# === G: Hold (via job_id) ===
|
||||
hold = env['fusion.plating.quality.hold'].create({
|
||||
'job_id': jobs[0].id, 'hold_reason': 'qc_failure', 'qty_on_hold': 1,
|
||||
'description': 'E2E test hold',
|
||||
})
|
||||
print(f'G. Hold: {hold.name}')
|
||||
assert hold.name == f'HOLD-{parent}'
|
||||
|
||||
# === H: RMA (via sale_order_id directly) ===
|
||||
rma = env['fusion.plating.rma'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
||||
print(f'H. RMA: {rma.name}')
|
||||
assert rma.name == f'RMA-{parent}'
|
||||
|
||||
# === I: NCR + CAPA (no SO link in core -> legacy seq) ===
|
||||
ncr = env['fusion.plating.ncr'].create({
|
||||
'description': 'E2E test', 'customer_partner_id': partner.id, 'facility_id': facility.id,
|
||||
})
|
||||
print(f'I. NCR (no SO link): {ncr.name}')
|
||||
assert not ncr.name.startswith('NCR-3'), f'expected legacy seq, got {ncr.name}'
|
||||
|
||||
capa = env['fusion.plating.capa'].create({
|
||||
'description': 'E2E test capa', 'ncr_id': ncr.id, 'facility_id': facility.id,
|
||||
})
|
||||
print(f'I. CAPA (no SO link): {capa.name}')
|
||||
assert not capa.name.startswith('CAPA-3'), f'expected legacy seq, got {capa.name}'
|
||||
|
||||
# === J: Immutability ===
|
||||
try:
|
||||
jobs[0].name = 'HACKED'
|
||||
print('FAIL J: name mutation succeeded')
|
||||
except UserError:
|
||||
print('J. OK: WO name immutable')
|
||||
|
||||
# === K: Unlink block ===
|
||||
try:
|
||||
coc.unlink()
|
||||
print('FAIL K: unlink succeeded')
|
||||
except UserError:
|
||||
print('K. OK: CoC unlink blocked')
|
||||
|
||||
# === L: Direct invoice creation block ===
|
||||
try:
|
||||
AM.create({
|
||||
'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id,
|
||||
})
|
||||
print('FAIL L: direct invoice succeeded')
|
||||
except UserError:
|
||||
print('L. OK: direct invoice blocked')
|
||||
|
||||
print('=' * 60)
|
||||
print(f'PASS: every doc tied to parent {parent}')
|
||||
print('=' * 60)
|
||||
env.cr.rollback()
|
||||
print('(rolled back - DB unchanged)')
|
||||
@@ -1,434 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Seeds 5-8 fp.job rows in each lifecycle state to simulate a live
|
||||
# shop floor. Run after cleanup_demo_data.py.
|
||||
#
|
||||
# Strategy:
|
||||
# 1. Find seedable customer/part combos. Prefer parts with a coating
|
||||
# (so the SO-confirm flow runs end-to-end), but fall back to
|
||||
# direct fp.job creation with the only available recipe so we get
|
||||
# customer variety.
|
||||
# 2. For each target state, create N jobs and manipulate their
|
||||
# lifecycle state + step state to simulate a live shop.
|
||||
#
|
||||
# Usage: load this file from inside `odoo shell` via the standard
|
||||
# pattern documented in scripts/README.md.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
random.seed(42) # reproducible
|
||||
|
||||
|
||||
def _build_combos(env):
|
||||
"""Return two lists:
|
||||
- via_so: (partner, part, coating) - for SO-confirm flow
|
||||
- direct: (partner, part_or_None, recipe) - for direct fp.job create
|
||||
|
||||
`via_so` requires a part with x_fc_default_coating_config_id whose
|
||||
recipe_id is set. `direct` covers all other customers/parts.
|
||||
"""
|
||||
via_so = []
|
||||
direct = []
|
||||
|
||||
# Prefer the canonical recipe; fall back to any recipe with operations.
|
||||
recipe = env['fusion.plating.process.node'].search([
|
||||
('node_type', '=', 'recipe'),
|
||||
('name', '=', 'ENP-ALUM-BASIC'),
|
||||
], limit=1)
|
||||
if not recipe:
|
||||
recipe = env['fusion.plating.process.node'].search([
|
||||
('node_type', '=', 'recipe'),
|
||||
], limit=1)
|
||||
if not recipe:
|
||||
print('ERROR: no recipes found. Cannot seed.')
|
||||
return via_so, direct, None
|
||||
|
||||
parts = env['fp.part.catalog'].search([])
|
||||
for p in parts:
|
||||
if not p.partner_id:
|
||||
continue
|
||||
if p.x_fc_default_coating_config_id and p.x_fc_default_coating_config_id.recipe_id:
|
||||
via_so.append((p.partner_id, p, p.x_fc_default_coating_config_id))
|
||||
else:
|
||||
direct.append((p.partner_id, p, recipe))
|
||||
|
||||
return via_so, direct, recipe
|
||||
|
||||
|
||||
def _create_so(env, partner, part, coating, qty, deadline_offset_days):
|
||||
"""Create + confirm a SO with one plating line. Returns (so, job)."""
|
||||
# fp.part.catalog has no product_id field - use a generic product
|
||||
# for the SO line. Plating-specific fields (x_fc_part_catalog_id,
|
||||
# x_fc_coating_config_id) carry the real linkage.
|
||||
fallback_product = env['product.product'].search(
|
||||
[('sale_ok', '=', True)], limit=1)
|
||||
if not fallback_product:
|
||||
fallback_product = env['product.product'].search([], limit=1)
|
||||
line_vals = {
|
||||
'product_id': fallback_product.id,
|
||||
'product_uom_qty': qty,
|
||||
'price_unit': 50.0 + qty * 2,
|
||||
}
|
||||
SOL_fields = env['sale.order.line']._fields
|
||||
if 'x_fc_part_catalog_id' in SOL_fields:
|
||||
line_vals['x_fc_part_catalog_id'] = part.id
|
||||
if 'x_fc_coating_config_id' in SOL_fields:
|
||||
line_vals['x_fc_coating_config_id'] = coating.id
|
||||
|
||||
so = env['sale.order'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'client_order_ref': 'SEED-%s' % datetime.now().strftime('%H%M%S%f')[:10],
|
||||
'commitment_date': datetime.now() + timedelta(days=deadline_offset_days),
|
||||
'order_line': [(0, 0, line_vals)],
|
||||
})
|
||||
try:
|
||||
so.action_confirm()
|
||||
except Exception as e:
|
||||
print(' WARN: SO confirm failed for %s (%s) - %s' % (so.name, partner.name, e))
|
||||
return so, env['fp.job']
|
||||
job = env['fp.job'].sudo().search([('sale_order_id', '=', so.id)], limit=1)
|
||||
return so, job
|
||||
|
||||
|
||||
def _create_job_direct(env, partner, part, recipe, qty, deadline_offset_days):
|
||||
"""Direct fp.job create (skips the SO-confirm hook)."""
|
||||
Job = env['fp.job'].sudo()
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'qty': qty,
|
||||
'date_deadline': datetime.now() + timedelta(days=deadline_offset_days),
|
||||
'recipe_id': recipe.id,
|
||||
'priority': random.choice(['low', 'normal', 'normal', 'high']),
|
||||
'quoted_revenue': 50.0 + qty * 2,
|
||||
}
|
||||
if part:
|
||||
vals['part_catalog_id'] = part.id
|
||||
# fp.part.catalog has no product_id field - leave fp.job.product_id
|
||||
# null. It's an optional field used as a "Reference Product".
|
||||
return Job.create(vals)
|
||||
|
||||
|
||||
def _operators(env):
|
||||
g = env.ref('fusion_plating.group_fusion_plating_operator',
|
||||
raise_if_not_found=False)
|
||||
if not g:
|
||||
return env['res.users']
|
||||
# Odoo 19: group <-> users m2m field on res.users is `all_group_ids`
|
||||
return env['res.users'].search([('all_group_ids', 'in', g.id)])
|
||||
|
||||
|
||||
def _confirm_and_steps(env, job):
|
||||
"""Drive a draft job through action_confirm + step generation."""
|
||||
if not job:
|
||||
return
|
||||
if job.state == 'draft':
|
||||
try:
|
||||
job.action_confirm()
|
||||
except Exception as e:
|
||||
print(' WARN: job %s action_confirm failed: %s' % (job.name, e))
|
||||
return
|
||||
if job.recipe_id and not job.step_ids:
|
||||
try:
|
||||
job._generate_steps_from_recipe()
|
||||
except Exception as e:
|
||||
print(' WARN: job %s step gen failed: %s' % (job.name, e))
|
||||
|
||||
|
||||
def run(env):
|
||||
print('=== Seeding fresh demo data ===')
|
||||
|
||||
via_so, direct, recipe = _build_combos(env)
|
||||
print(' via_so combos: %d' % len(via_so))
|
||||
print(' direct combos: %d' % len(direct))
|
||||
print(' recipe: %s' % (recipe.name if recipe else 'NONE'))
|
||||
if not recipe:
|
||||
return
|
||||
if not direct and not via_so:
|
||||
print('ERROR: no combos available. Cannot seed.')
|
||||
return
|
||||
|
||||
operators = _operators(env)
|
||||
print(' operators: %d' % len(operators))
|
||||
|
||||
counts = {
|
||||
'draft': 5,
|
||||
'confirmed': 6,
|
||||
'in_progress': 8,
|
||||
'on_hold': 3,
|
||||
'done': 6,
|
||||
'cancelled': 3,
|
||||
}
|
||||
|
||||
via_so_idx = 0
|
||||
direct_idx = 0
|
||||
|
||||
def _next_via_so():
|
||||
nonlocal via_so_idx
|
||||
if not via_so:
|
||||
return None
|
||||
c = via_so[via_so_idx % len(via_so)]
|
||||
via_so_idx += 1
|
||||
return c
|
||||
|
||||
def _next_direct():
|
||||
nonlocal direct_idx
|
||||
if not direct:
|
||||
return None
|
||||
c = direct[direct_idx % len(direct)]
|
||||
direct_idx += 1
|
||||
return c
|
||||
|
||||
def _next_combo(prefer_so=False):
|
||||
if prefer_so and via_so:
|
||||
return ('so', _next_via_so())
|
||||
if direct:
|
||||
return ('direct', _next_direct())
|
||||
if via_so:
|
||||
return ('so', _next_via_so())
|
||||
return (None, None)
|
||||
|
||||
created = {state: [] for state in counts}
|
||||
|
||||
# 1. DRAFT - direct create, do NOT confirm
|
||||
print('-- Creating draft jobs --')
|
||||
for i in range(counts['draft']):
|
||||
kind, combo = _next_combo()
|
||||
if not combo:
|
||||
break
|
||||
partner, part, coating_or_recipe = combo
|
||||
if kind == 'so':
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe.recipe_id,
|
||||
qty=random.choice([1, 5, 10, 25, 50]),
|
||||
deadline_offset_days=random.randint(7, 30),
|
||||
)
|
||||
if part.x_fc_default_coating_config_id:
|
||||
job.coating_config_id = part.x_fc_default_coating_config_id.id
|
||||
else:
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([1, 5, 10, 25, 50]),
|
||||
deadline_offset_days=random.randint(7, 30),
|
||||
)
|
||||
created['draft'].append(job)
|
||||
print(' draft: %s (%s)' % (job.name, partner.name))
|
||||
|
||||
# 2. CONFIRMED
|
||||
print('-- Creating confirmed jobs --')
|
||||
for i in range(counts['confirmed']):
|
||||
prefer_so = (i % 2 == 0)
|
||||
kind, combo = _next_combo(prefer_so=prefer_so)
|
||||
if not combo:
|
||||
break
|
||||
partner, part, coating_or_recipe = combo
|
||||
if kind == 'so':
|
||||
so, job = _create_so(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10, 25, 50, 100]),
|
||||
deadline_offset_days=random.randint(5, 25),
|
||||
)
|
||||
_confirm_and_steps(env, job)
|
||||
else:
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10, 25, 50, 100]),
|
||||
deadline_offset_days=random.randint(5, 25),
|
||||
)
|
||||
_confirm_and_steps(env, job)
|
||||
if job:
|
||||
created['confirmed'].append(job)
|
||||
print(' confirmed: %s (%s, %d steps)' % (
|
||||
job.name, partner.name, len(job.step_ids)))
|
||||
|
||||
# 3. IN_PROGRESS
|
||||
print('-- Creating in_progress jobs --')
|
||||
for i in range(counts['in_progress']):
|
||||
kind, combo = _next_combo()
|
||||
if not combo:
|
||||
break
|
||||
partner, part, coating_or_recipe = combo
|
||||
if kind == 'so':
|
||||
so, job = _create_so(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10, 25, 50]),
|
||||
deadline_offset_days=random.randint(3, 15),
|
||||
)
|
||||
else:
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10, 25, 50]),
|
||||
deadline_offset_days=random.randint(3, 15),
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
_confirm_and_steps(env, job)
|
||||
job.state = 'in_progress'
|
||||
job.date_started = datetime.now() - timedelta(days=random.randint(1, 5))
|
||||
steps = job.step_ids.sorted('sequence')
|
||||
if not steps:
|
||||
print(' WARN: in_progress job %s has no steps' % job.name)
|
||||
created['in_progress'].append(job)
|
||||
continue
|
||||
for s in steps:
|
||||
if operators:
|
||||
s.assigned_user_id = operators[
|
||||
random.randrange(len(operators))
|
||||
]
|
||||
n_done = max(1, int(len(steps) * random.uniform(0.3, 0.6)))
|
||||
for s in steps[:n_done]:
|
||||
s.state = 'done'
|
||||
s.date_started = datetime.now() - timedelta(
|
||||
hours=random.randint(2, 48))
|
||||
s.date_finished = s.date_started + timedelta(
|
||||
minutes=random.randint(15, 240))
|
||||
s.duration_actual = (
|
||||
s.date_finished - s.date_started).total_seconds() / 60.0
|
||||
s.started_by_user_id = s.assigned_user_id or env.user
|
||||
s.finished_by_user_id = s.assigned_user_id or env.user
|
||||
if n_done < len(steps):
|
||||
cur = steps[n_done]
|
||||
cur.state = 'in_progress'
|
||||
cur.date_started = datetime.now() - timedelta(
|
||||
minutes=random.randint(5, 90))
|
||||
cur.started_by_user_id = cur.assigned_user_id or env.user
|
||||
env['fp.job.step.timelog'].sudo().create({
|
||||
'step_id': cur.id,
|
||||
'user_id': (cur.assigned_user_id.id
|
||||
if cur.assigned_user_id else env.user.id),
|
||||
'date_started': cur.date_started,
|
||||
})
|
||||
if n_done + 1 < len(steps):
|
||||
steps[n_done + 1].state = 'ready'
|
||||
created['in_progress'].append(job)
|
||||
print(' in_progress: %s (%s, %d/%d done)' % (
|
||||
job.name, partner.name, n_done, len(steps)))
|
||||
|
||||
# 4. ON_HOLD
|
||||
print('-- Creating on_hold jobs --')
|
||||
for i in range(counts['on_hold']):
|
||||
kind, combo = _next_combo()
|
||||
if not combo:
|
||||
break
|
||||
partner, part, coating_or_recipe = combo
|
||||
if kind == 'so':
|
||||
so, job = _create_so(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10, 25]),
|
||||
deadline_offset_days=random.randint(5, 20),
|
||||
)
|
||||
else:
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10, 25]),
|
||||
deadline_offset_days=random.randint(5, 20),
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
_confirm_and_steps(env, job)
|
||||
steps = job.step_ids.sorted('sequence')
|
||||
for s in steps[:2]:
|
||||
s.state = 'done'
|
||||
s.date_finished = datetime.now() - timedelta(days=1)
|
||||
s.date_started = s.date_finished - timedelta(minutes=60)
|
||||
s.duration_actual = 60.0
|
||||
if len(steps) > 2:
|
||||
steps[2].state = 'paused'
|
||||
steps[2].date_started = datetime.now() - timedelta(hours=4)
|
||||
job.state = 'on_hold'
|
||||
created['on_hold'].append(job)
|
||||
print(' on_hold: %s (%s)' % (job.name, partner.name))
|
||||
|
||||
# 5. DONE
|
||||
print('-- Creating done jobs --')
|
||||
for i in range(counts['done']):
|
||||
kind, combo = _next_combo()
|
||||
if not combo:
|
||||
break
|
||||
partner, part, coating_or_recipe = combo
|
||||
if kind == 'so':
|
||||
so, job = _create_so(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([1, 5, 10, 25]),
|
||||
deadline_offset_days=random.randint(-5, 5),
|
||||
)
|
||||
else:
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([1, 5, 10, 25]),
|
||||
deadline_offset_days=random.randint(-5, 5),
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
_confirm_and_steps(env, job)
|
||||
steps = job.step_ids.sorted('sequence')
|
||||
for j, s in enumerate(steps):
|
||||
s.state = 'done'
|
||||
offset = (len(steps) - j) * 30
|
||||
s.date_started = datetime.now() - timedelta(minutes=offset + 30)
|
||||
s.date_finished = datetime.now() - timedelta(minutes=offset)
|
||||
s.duration_actual = 30.0
|
||||
if operators:
|
||||
op = operators[random.randrange(len(operators))]
|
||||
s.assigned_user_id = op
|
||||
s.started_by_user_id = op
|
||||
s.finished_by_user_id = op
|
||||
# Set state directly to avoid downstream side effects (delivery
|
||||
# + cert auto-create) on demo data.
|
||||
job.state = 'done'
|
||||
job.date_finished = datetime.now() - timedelta(
|
||||
hours=random.randint(1, 48))
|
||||
job.date_started = datetime.now() - timedelta(days=2)
|
||||
created['done'].append(job)
|
||||
print(' done: %s (%s)' % (job.name, partner.name))
|
||||
|
||||
# 6. CANCELLED
|
||||
print('-- Creating cancelled jobs --')
|
||||
for i in range(counts['cancelled']):
|
||||
kind, combo = _next_combo()
|
||||
if not combo:
|
||||
break
|
||||
partner, part, coating_or_recipe = combo
|
||||
if kind == 'so':
|
||||
so, job = _create_so(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10]),
|
||||
deadline_offset_days=random.randint(10, 30),
|
||||
)
|
||||
else:
|
||||
job = _create_job_direct(
|
||||
env, partner, part, coating_or_recipe,
|
||||
qty=random.choice([5, 10]),
|
||||
deadline_offset_days=random.randint(10, 30),
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
_confirm_and_steps(env, job)
|
||||
try:
|
||||
job.action_cancel()
|
||||
except Exception:
|
||||
job.state = 'cancelled'
|
||||
created['cancelled'].append(job)
|
||||
print(' cancelled: %s (%s)' % (job.name, partner.name))
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
print()
|
||||
print('=== Seed summary ===')
|
||||
for state, jobs in created.items():
|
||||
print(' %s: %d jobs' % (state, len(jobs)))
|
||||
|
||||
print()
|
||||
print('=== Verification ===')
|
||||
Job = env['fp.job']
|
||||
for state in counts:
|
||||
print(' fp.job state=%s: actual=%d' % (
|
||||
state, Job.search_count([('state', '=', state)])))
|
||||
|
||||
|
||||
try:
|
||||
run(env)
|
||||
except NameError:
|
||||
print('Run inside `odoo shell`.')
|
||||
@@ -1,777 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# seed_direct_orders.py
|
||||
# =====================
|
||||
# Creates 8-12 sale orders that originate from the estimator's
|
||||
# direct-order-entry path -- i.e. via fp.direct.order.wizard.action_create_order
|
||||
# -- instead of plain sale.order.create. This exercises the wizard
|
||||
# code path which currently has zero seeded data.
|
||||
#
|
||||
# The wizard:
|
||||
# - Validates PO# / PO doc OR po_pending flag (we use po_pending for some)
|
||||
# - Creates the SO in DRAFT state with one SO line per wizard line
|
||||
# - Returns an action with res_id pointing at the new SO
|
||||
# - Does NOT auto-confirm (Sub 1 deliberately removed auto-confirm)
|
||||
#
|
||||
# So this script:
|
||||
# 1. Builds a wizard with realistic header fields + 1-3 lines
|
||||
# 2. Calls action_create_order() to materialise the draft SO
|
||||
# 3. Calls so.action_confirm() to fire job creation (the ON-confirm
|
||||
# _fp_auto_create_job hook builds the fp.job + steps)
|
||||
# 4. Optionally advances the resulting job/SO across workflow states,
|
||||
# reusing the helpers from seed_workflow_states.py
|
||||
#
|
||||
# Distribution of 8-12 orders across states (matches client request):
|
||||
# - 3 stay at "Confirmed / Job just generated steps"
|
||||
# - 3 advance to "Job In Progress (mid)"
|
||||
# - 2 advance to "Job Done / Delivery Scheduled"
|
||||
# - 2 advance all the way to "Delivered + Invoice Posted"
|
||||
# - 1-2 advance to "Paid"
|
||||
# Total = 11-12 orders.
|
||||
#
|
||||
# Each order is wrapped in its own savepoint -- failure on one doesn't
|
||||
# nuke the whole run. Savepoint names are alphanumeric only because
|
||||
# Postgres rejects parens/dots in identifiers.
|
||||
#
|
||||
# Usage: see scripts/README.md.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import base64
|
||||
import random
|
||||
import logging
|
||||
|
||||
random.seed(2027)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Combo / context helpers (mirror seed_workflow_states.py) #
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _build_combos(env):
|
||||
"""List of (partner, part, coating) tuples that have a recipe."""
|
||||
combos = []
|
||||
parts = env["fp.part.catalog"].search([
|
||||
("x_fc_default_coating_config_id", "!=", False),
|
||||
("x_fc_default_coating_config_id.recipe_id", "!=", False),
|
||||
("partner_id", "!=", False),
|
||||
])
|
||||
for p in parts:
|
||||
combos.append((p.partner_id, p, p.x_fc_default_coating_config_id))
|
||||
random.shuffle(combos)
|
||||
return combos
|
||||
|
||||
|
||||
def _operators(env):
|
||||
g = env.ref("fusion_plating.group_fusion_plating_operator",
|
||||
raise_if_not_found=False)
|
||||
if not g:
|
||||
return env["res.users"]
|
||||
return env["res.users"].search([("all_group_ids", "in", g.id)])
|
||||
|
||||
|
||||
def _managers(env):
|
||||
g = env.ref("fusion_plating.group_fusion_plating_manager",
|
||||
raise_if_not_found=False)
|
||||
if not g:
|
||||
return env["res.users"]
|
||||
return env["res.users"].search([("all_group_ids", "in", g.id)])
|
||||
|
||||
|
||||
def _employees(env):
|
||||
return env["hr.employee"].search([])
|
||||
|
||||
|
||||
def _resolve_payment_term(env):
|
||||
pt = env["account.payment.term"].search(
|
||||
[("name", "=", "30 Days")], limit=1)
|
||||
if not pt:
|
||||
pt = env["account.payment.term"].search([], limit=1)
|
||||
return pt
|
||||
|
||||
|
||||
def _resolve_journals(env):
|
||||
sales = env["account.journal"].search([("type", "=", "sale")], limit=1)
|
||||
bank = env["account.journal"].search([("type", "=", "bank")], limit=1)
|
||||
return sales, bank
|
||||
|
||||
|
||||
def _resolve_facility(env):
|
||||
return env["fusion.plating.facility"].search([], limit=1)
|
||||
|
||||
|
||||
def _selection_values(model, fname):
|
||||
"""Return the list of valid keys for a Selection field, or []."""
|
||||
fld = model._fields.get(fname)
|
||||
if not fld or fld.type != "selection":
|
||||
return []
|
||||
sel = fld.selection
|
||||
if callable(sel):
|
||||
try:
|
||||
sel = sel(model)
|
||||
except Exception:
|
||||
return []
|
||||
return [k for (k, _label) in sel] if sel else []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Wizard build #
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _pick_treatments(env, n):
|
||||
"""Return a recordset of n treatments (or empty if treatments missing)."""
|
||||
Treatment = env.get("fp.treatment")
|
||||
if Treatment is None:
|
||||
return env["fp.treatment"].browse([]) if "fp.treatment" in env else None
|
||||
pool = env["fp.treatment"].search([], limit=20)
|
||||
if not pool or n <= 0:
|
||||
return env["fp.treatment"].browse([])
|
||||
n = min(n, len(pool))
|
||||
picked = random.sample(list(pool), n)
|
||||
return env["fp.treatment"].browse([t.id for t in picked])
|
||||
|
||||
|
||||
def _build_wizard(env, partner, lines_data, ctx, idx):
|
||||
"""Build a fp.direct.order.wizard with realistic header + lines.
|
||||
|
||||
`lines_data` is a list of dicts, one per line, each with keys:
|
||||
part, coating, quantity, unit_price, ...
|
||||
"""
|
||||
Wizard = env["fp.direct.order.wizard"].sudo()
|
||||
WLine = env["fp.direct.order.line"].sudo()
|
||||
|
||||
addrs = partner.address_get(["invoice", "delivery"])
|
||||
cust_dl = (datetime.now() + timedelta(days=random.randint(7, 30))).date()
|
||||
int_dl = cust_dl - timedelta(days=3)
|
||||
plan_start = (datetime.now() + timedelta(days=random.randint(1, 5))).date()
|
||||
po_exp = cust_dl - timedelta(days=random.randint(2, 7))
|
||||
|
||||
delivery_methods = _selection_values(Wizard, "delivery_method")
|
||||
invoice_strategies = _selection_values(Wizard, "invoice_strategy")
|
||||
|
||||
notes_pool = [
|
||||
"Direct entry by estimator -- repeat customer, standard ENP per "
|
||||
"AMS-2404. Rush capacity if available.",
|
||||
"Customer phoned in PO -- bulk re-order of last month's run. Use "
|
||||
"same recipe, same masking. Confirm thickness on first piece.",
|
||||
"Standing order -- expedite if over 10% of capacity is free. "
|
||||
"Mask threads as before. CoC + thickness report required.",
|
||||
"Estimator entry, customer requires same-day acknowledgment. "
|
||||
"Watch for hex / barrel mix on the racks.",
|
||||
"Direct re-order, shipper to call ahead before pickup. Pack in "
|
||||
"original boxes. No partial shipments.",
|
||||
]
|
||||
|
||||
po_pending = (random.random() < 0.30)
|
||||
has_po_doc = (random.random() < 0.40) if not po_pending else False
|
||||
|
||||
wiz_vals = {
|
||||
"partner_id": partner.id,
|
||||
"partner_invoice_id": addrs.get("invoice") or partner.id,
|
||||
"partner_shipping_id": addrs.get("delivery") or partner.id,
|
||||
"customer_job_number": "CJN-D%04d" % idx,
|
||||
"planned_start_date": plan_start,
|
||||
"internal_deadline": int_dl,
|
||||
"customer_deadline": cust_dl,
|
||||
"is_blanket_order": (random.random() < 0.20),
|
||||
"block_partial_shipments": (random.random() < 0.30),
|
||||
"po_pending": po_pending,
|
||||
"po_expected_date": po_exp if po_pending else False,
|
||||
"po_number": False if po_pending else "PO-D%04d" % idx,
|
||||
"notes": random.choice(notes_pool),
|
||||
}
|
||||
if delivery_methods:
|
||||
wiz_vals["delivery_method"] = random.choice(delivery_methods)
|
||||
if invoice_strategies:
|
||||
strat = random.choice(invoice_strategies)
|
||||
wiz_vals["invoice_strategy"] = strat
|
||||
if strat == "deposit":
|
||||
wiz_vals["deposit_percent"] = random.choice([15.0, 25.0, 33.0])
|
||||
elif strat == "progress":
|
||||
wiz_vals["progress_initial_percent"] = random.choice(
|
||||
[40.0, 50.0, 60.0])
|
||||
|
||||
# Attach a fake PO doc if we need one
|
||||
if has_po_doc and not po_pending:
|
||||
fake_pdf = b"%PDF-1.4 fake po placeholder for seed data\n%%EOF\n"
|
||||
wiz_vals["po_attachment_file"] = base64.b64encode(fake_pdf).decode()
|
||||
wiz_vals["po_attachment_filename"] = "po_seed_%04d.pdf" % idx
|
||||
elif not po_pending and not has_po_doc:
|
||||
# Wizard requires either a PO doc OR po_pending -- force a doc
|
||||
# if we got here with neither. Better to attach than to fail.
|
||||
fake_pdf = b"%PDF-1.4 fake po placeholder for seed data\n%%EOF\n"
|
||||
wiz_vals["po_attachment_file"] = base64.b64encode(fake_pdf).decode()
|
||||
wiz_vals["po_attachment_filename"] = "po_seed_%04d.pdf" % idx
|
||||
|
||||
wizard = Wizard.create(wiz_vals)
|
||||
|
||||
# Build a shared wo_group_tag for ~30% of orders so multiple lines
|
||||
# roll up into one job (tests the multi-line-collapse path)
|
||||
use_group_tag = (len(lines_data) > 1) and (random.random() < 0.30)
|
||||
group_tag = "G%d" % random.randint(1, 9) if use_group_tag else False
|
||||
|
||||
surface_area_uoms = _selection_values(WLine, "surface_area_uom")
|
||||
line_descs = [
|
||||
"Mask threads, ENP per AMS-2404 Class 4. Pack in vendor boxes.",
|
||||
"Standard ENP, 0.0005-0.001 inch thickness. Bake 4hr @ 400F.",
|
||||
"Re-work job: strip + replate. Verify base before activation.",
|
||||
"Heavy duty ENP, mid-phos. Mask all threaded holes per drawing.",
|
||||
"Light ENP barrier, mil-spec. Customer requires CoC + thickness.",
|
||||
]
|
||||
int_descs = [
|
||||
"Mask 1/4-20 threads. ENP per AMS-2404 Class 4 mid-phos. "
|
||||
"Watch for racking marks.",
|
||||
"Standard alkaline EN bath. Target 0.0005 in. Spot-check 5 pcs "
|
||||
"with Fischerscope before bake.",
|
||||
"Strip in nitric, neutralise, activate. Replate to drawing spec. "
|
||||
"First-piece check required.",
|
||||
"Heavy ENP -- expect 6+ hr in tank. Mask blind holes per "
|
||||
"engineering note. Bake 4hr @ 400F.",
|
||||
"Light barrier coat for corrosion. CoC + thickness report on "
|
||||
"delivery. No exceptions on cleanliness.",
|
||||
]
|
||||
wo_descs = [
|
||||
"ENP plating, mask threads, pack in vendor boxes.",
|
||||
"Standard ENP run, mid-phos, bake 4hr.",
|
||||
"Strip + replate, verify base material first.",
|
||||
"Heavy ENP, 6+hr tank, mask all blind holes.",
|
||||
"Light ENP barrier, full QC pack-out.",
|
||||
]
|
||||
|
||||
for ld in lines_data:
|
||||
part = ld["part"]
|
||||
coating = ld["coating"]
|
||||
qty = ld["quantity"]
|
||||
price = ld["unit_price"]
|
||||
treatments = _pick_treatments(env, random.randint(0, 2))
|
||||
|
||||
line_vals = {
|
||||
"wizard_id": wizard.id,
|
||||
"part_catalog_id": part.id,
|
||||
"coating_config_id": coating.id,
|
||||
"quantity": qty,
|
||||
"unit_price": price,
|
||||
"line_description": random.choice(line_descs),
|
||||
"internal_description": random.choice(int_descs),
|
||||
"part_wo_description": random.choice(wo_descs),
|
||||
"rush_order": (random.random() < 0.15),
|
||||
"is_one_off": False,
|
||||
"push_to_defaults": False,
|
||||
}
|
||||
if treatments:
|
||||
line_vals["treatment_ids"] = [(6, 0, treatments.ids)]
|
||||
if group_tag:
|
||||
line_vals["wo_group_tag"] = group_tag
|
||||
# Per-line deadline within the order window
|
||||
line_vals["part_deadline"] = (
|
||||
cust_dl - timedelta(days=random.randint(0, 3))
|
||||
)
|
||||
|
||||
WLine.create(line_vals)
|
||||
|
||||
return wizard
|
||||
|
||||
|
||||
def _create_so_via_wizard(env, partner, combos_for_partner, n_lines, idx):
|
||||
"""Build wizard, run action_create_order, return the SO record."""
|
||||
if not combos_for_partner:
|
||||
return None
|
||||
# Allow up to n_lines distinct combos for this partner; if the partner
|
||||
# only has one, just repeat it (different qty / price).
|
||||
chosen = []
|
||||
pool = list(combos_for_partner)
|
||||
random.shuffle(pool)
|
||||
while len(chosen) < n_lines and pool:
|
||||
chosen.append(pool.pop())
|
||||
while len(chosen) < n_lines:
|
||||
chosen.append(random.choice(combos_for_partner))
|
||||
|
||||
lines_data = []
|
||||
for (_partner, part, coating) in chosen:
|
||||
lines_data.append({
|
||||
"part": part,
|
||||
"coating": coating,
|
||||
"quantity": random.randint(5, 100),
|
||||
"unit_price": round(random.uniform(50.0, 300.0), 2),
|
||||
})
|
||||
|
||||
wizard = _build_wizard(env, partner, lines_data, None, idx)
|
||||
action = wizard.action_create_order()
|
||||
if not action or not action.get("res_id"):
|
||||
return None
|
||||
return env["sale.order"].browse(action["res_id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# State-advancement helpers (adapted from seed_workflow_states.py) #
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _ensure_steps(env, job):
|
||||
if not job or not job.recipe_id or job.step_ids:
|
||||
return
|
||||
try:
|
||||
job._generate_steps_from_recipe()
|
||||
except Exception as e:
|
||||
_logger.warning("Step gen failed for %s: %s", job.name, e)
|
||||
|
||||
|
||||
def _populate_job(env, job, ctx):
|
||||
if not job:
|
||||
return
|
||||
vals = {}
|
||||
if ctx["facility"] and not job.facility_id:
|
||||
vals["facility_id"] = ctx["facility"].id
|
||||
if ctx["managers"] and not job.manager_id:
|
||||
vals["manager_id"] = random.choice(ctx["managers"]).id
|
||||
if not job.priority or job.priority == "normal":
|
||||
vals["priority"] = random.choices(
|
||||
["low", "normal", "high", "rush"],
|
||||
weights=[10, 70, 15, 5],
|
||||
)[0]
|
||||
if vals:
|
||||
job.write(vals)
|
||||
|
||||
|
||||
def _assign_step_users(env, job, ctx, n_done=0, current_idx=None):
|
||||
operators = ctx["operators"]
|
||||
steps = job.step_ids.sorted("sequence")
|
||||
if not steps:
|
||||
return
|
||||
for s in steps:
|
||||
if operators and not s.assigned_user_id:
|
||||
s.assigned_user_id = operators[
|
||||
random.randrange(len(operators))]
|
||||
|
||||
base = datetime.now() - timedelta(hours=len(steps) * 2)
|
||||
for i, s in enumerate(steps[:n_done]):
|
||||
start = base + timedelta(hours=i * 2)
|
||||
finish = start + timedelta(minutes=random.randint(20, 90))
|
||||
uid = (s.assigned_user_id.id
|
||||
if s.assigned_user_id else env.user.id)
|
||||
s.write({
|
||||
"state": "done",
|
||||
"date_started": start,
|
||||
"date_finished": finish,
|
||||
"duration_actual": (finish - start).total_seconds() / 60.0,
|
||||
"started_by_user_id": uid,
|
||||
"finished_by_user_id": uid,
|
||||
})
|
||||
env["fp.job.step.timelog"].sudo().create({
|
||||
"step_id": s.id,
|
||||
"user_id": uid,
|
||||
"date_started": start,
|
||||
"date_finished": finish,
|
||||
"duration_minutes": (finish - start).total_seconds() / 60.0,
|
||||
})
|
||||
|
||||
if current_idx is not None and current_idx < len(steps):
|
||||
cur = steps[current_idx]
|
||||
if cur.state != "done":
|
||||
start = datetime.now() - timedelta(
|
||||
minutes=random.randint(5, 90))
|
||||
uid = (cur.assigned_user_id.id
|
||||
if cur.assigned_user_id else env.user.id)
|
||||
cur.write({
|
||||
"state": "in_progress",
|
||||
"date_started": start,
|
||||
"started_by_user_id": uid,
|
||||
})
|
||||
env["fp.job.step.timelog"].sudo().create({
|
||||
"step_id": cur.id,
|
||||
"user_id": uid,
|
||||
"date_started": start,
|
||||
})
|
||||
|
||||
if current_idx is not None and current_idx + 1 < len(steps):
|
||||
nxt = steps[current_idx + 1]
|
||||
if nxt.state == "pending":
|
||||
nxt.write({"state": "ready"})
|
||||
|
||||
|
||||
def _make_delivery_full(env, delivery, partner, ctx, state,
|
||||
scheduled_offset_days=1):
|
||||
if not delivery:
|
||||
return
|
||||
employees = ctx["employees"]
|
||||
facility = ctx["facility"]
|
||||
vals = {
|
||||
"delivery_address_id": partner.id,
|
||||
"contact_name": partner.name,
|
||||
"contact_phone": partner.phone or (
|
||||
"555-%04d" % random.randint(1000, 9999)),
|
||||
"scheduled_date": datetime.now() + timedelta(
|
||||
days=scheduled_offset_days),
|
||||
}
|
||||
if "x_fc_box_count_out" in delivery._fields:
|
||||
vals["x_fc_box_count_out"] = random.randint(1, 5)
|
||||
if employees and "assigned_driver_id" in delivery._fields:
|
||||
vals["assigned_driver_id"] = employees[
|
||||
random.randrange(len(employees))].id
|
||||
if facility and "source_facility_id" in delivery._fields:
|
||||
vals["source_facility_id"] = facility.id
|
||||
if "notes" in delivery._fields:
|
||||
vals["notes"] = (
|
||||
"<p>Direct-order delivery -- pack in original boxes per "
|
||||
"customer SOP.</p>")
|
||||
delivery.write(vals)
|
||||
delivery.write({"state": state})
|
||||
if state == "delivered":
|
||||
delivery.write({"delivered_at": datetime.now() - timedelta(
|
||||
hours=random.randint(1, 48))})
|
||||
|
||||
|
||||
def _issue_certificate(env, job, so, part, ctx):
|
||||
cert = env["fp.certificate"].search(
|
||||
[("x_fc_job_id", "=", job.id)], limit=1)
|
||||
if not cert:
|
||||
cert = env["fp.certificate"].sudo().create({
|
||||
"partner_id": job.partner_id.id,
|
||||
"certificate_type": "coc",
|
||||
"state": "draft",
|
||||
"x_fc_job_id": job.id,
|
||||
"sale_order_id": so.id if so else False,
|
||||
})
|
||||
vals = {
|
||||
"state": "issued",
|
||||
"issue_date": datetime.now().date(),
|
||||
"issued_by_id": env.user.id,
|
||||
"entech_wo_number": job.name,
|
||||
"customer_job_no": (so.x_fc_customer_job_number
|
||||
if so and "x_fc_customer_job_number" in so._fields
|
||||
else (so.client_order_ref if so else "")),
|
||||
"po_number": (so.x_fc_po_number
|
||||
if so and "x_fc_po_number" in so._fields else ""),
|
||||
"quantity_shipped": int(job.qty or 1),
|
||||
"part_number": part.part_number or part.name,
|
||||
"process_description": "Electroless Nickel Plating, MIL-C-26074",
|
||||
}
|
||||
if "spec_min_mils" in cert._fields and part.x_fc_default_coating_config_id:
|
||||
c = part.x_fc_default_coating_config_id
|
||||
if c.thickness_min:
|
||||
vals["spec_min_mils"] = c.thickness_min
|
||||
if c.thickness_max:
|
||||
vals["spec_max_mils"] = c.thickness_max
|
||||
vals["spec_reference"] = c.spec_reference or "AMS-2404"
|
||||
cert.write(vals)
|
||||
for i in range(5):
|
||||
env["fp.thickness.reading"].sudo().create({
|
||||
"certificate_id": cert.id,
|
||||
"reading_number": i + 1,
|
||||
"nip_mils": round(random.uniform(0.95, 1.15), 3),
|
||||
"ni_percent": round(random.uniform(88.0, 92.0), 2),
|
||||
"p_percent": round(random.uniform(8.0, 12.0), 2),
|
||||
"position_label": "Pos %d" % (i + 1),
|
||||
"reading_datetime": datetime.now() - timedelta(
|
||||
minutes=30 - i * 5),
|
||||
"operator_id": env.user.id,
|
||||
"x_fc_job_id": job.id,
|
||||
"equipment_model": "Fischerscope X-Ray XDV-SD",
|
||||
"calibration_std_ref": "CAL-2026-04-01",
|
||||
})
|
||||
return cert
|
||||
|
||||
|
||||
def _create_invoice(env, so, ctx, post=False):
|
||||
inv_recordset = so._create_invoices()
|
||||
if not inv_recordset:
|
||||
return env["account.move"]
|
||||
inv = (inv_recordset[0] if hasattr(inv_recordset, "ids")
|
||||
else env["account.move"].browse(inv_recordset))
|
||||
inv_vals = {
|
||||
"invoice_date": (datetime.now() - timedelta(
|
||||
days=random.randint(0, 5))).date(),
|
||||
"invoice_date_due": (datetime.now() + timedelta(
|
||||
days=random.randint(15, 30))).date(),
|
||||
}
|
||||
if not inv.invoice_payment_term_id:
|
||||
inv_vals["invoice_payment_term_id"] = ctx["payment_term"].id
|
||||
inv.write(inv_vals)
|
||||
if post:
|
||||
inv.action_post()
|
||||
return inv
|
||||
|
||||
|
||||
def _register_payment(env, inv, ctx, validate=True):
|
||||
bank = ctx["bank_journal"]
|
||||
pml = bank.inbound_payment_method_line_ids[:1]
|
||||
wizard = env["account.payment.register"].with_context(
|
||||
active_model="account.move",
|
||||
active_ids=inv.ids,
|
||||
).create({
|
||||
"amount": inv.amount_total,
|
||||
"journal_id": bank.id,
|
||||
"payment_method_line_id": pml.id if pml else False,
|
||||
"payment_date": (datetime.now() - timedelta(
|
||||
days=random.randint(0, 7))).date(),
|
||||
})
|
||||
wizard.action_create_payments()
|
||||
pmt = env["account.payment"].search(
|
||||
[("partner_id", "=", inv.partner_id.id),
|
||||
("amount", "=", inv.amount_total)],
|
||||
order="id desc", limit=1)
|
||||
if pmt and validate:
|
||||
try:
|
||||
pmt.action_validate()
|
||||
except Exception as e:
|
||||
_logger.warning("Payment validate failed: %s", e)
|
||||
try:
|
||||
pmt.write({"state": "paid"})
|
||||
except Exception as e2:
|
||||
_logger.warning("Payment direct write paid failed: %s", e2)
|
||||
return pmt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Per-state advancement #
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _advance_to_confirmed(env, so, ctx):
|
||||
"""Confirm SO; populate job + steps but leave it at draft job state."""
|
||||
if so.state != "sale":
|
||||
try:
|
||||
# Wizard stayed in draft. Sub 1 design: SO is left in draft
|
||||
# and reviewed before confirmation. We confirm here to
|
||||
# exercise the downstream auto-create-job hook.
|
||||
so.action_confirm()
|
||||
except Exception as e:
|
||||
_logger.warning("SO confirm failed for %s: %s", so.name, e)
|
||||
return False
|
||||
jobs = env["fp.job"].search([("sale_order_id", "=", so.id)])
|
||||
for job in jobs:
|
||||
if job.state == "draft":
|
||||
try:
|
||||
job.action_confirm()
|
||||
except Exception as e:
|
||||
_logger.warning("Job confirm failed: %s", e)
|
||||
continue
|
||||
_ensure_steps(env, job)
|
||||
_populate_job(env, job, ctx)
|
||||
_assign_step_users(env, job, ctx, n_done=0, current_idx=None)
|
||||
return True
|
||||
|
||||
|
||||
def _advance_to_in_progress_mid(env, so, ctx):
|
||||
if not _advance_to_confirmed(env, so, ctx):
|
||||
return False
|
||||
jobs = env["fp.job"].search([("sale_order_id", "=", so.id)])
|
||||
for job in jobs:
|
||||
total = len(job.step_ids)
|
||||
n_done = max(1, total // 2) if total else 0
|
||||
_assign_step_users(env, job, ctx, n_done=n_done,
|
||||
current_idx=n_done)
|
||||
job.write({
|
||||
"state": "in_progress",
|
||||
"date_started": datetime.now() - timedelta(
|
||||
days=random.randint(2, 7)),
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def _advance_to_delivered(env, so, ctx, deliver_state="scheduled"):
|
||||
"""Drive job to done + delivery to scheduled or delivered."""
|
||||
if not _advance_to_confirmed(env, so, ctx):
|
||||
return False
|
||||
jobs = env["fp.job"].search([("sale_order_id", "=", so.id)])
|
||||
if not jobs:
|
||||
return False
|
||||
for job in jobs:
|
||||
_assign_step_users(env, job, ctx,
|
||||
n_done=len(job.step_ids), current_idx=None)
|
||||
job.write({
|
||||
"state": "in_progress",
|
||||
"date_started": datetime.now() - timedelta(
|
||||
days=random.randint(3, 10)),
|
||||
})
|
||||
try:
|
||||
job.button_mark_done()
|
||||
except Exception as e:
|
||||
_logger.warning("Job mark_done failed: %s", e)
|
||||
continue
|
||||
if job.delivery_id:
|
||||
offset = (-2 if deliver_state == "delivered"
|
||||
else random.randint(1, 5))
|
||||
_make_delivery_full(env, job.delivery_id, so.partner_id, ctx,
|
||||
state=deliver_state,
|
||||
scheduled_offset_days=offset)
|
||||
if deliver_state == "delivered":
|
||||
# Issue cert for first part on the SO
|
||||
first_line = so.order_line[:1]
|
||||
part = (first_line.x_fc_part_catalog_id
|
||||
if first_line and "x_fc_part_catalog_id"
|
||||
in first_line._fields else False)
|
||||
if part:
|
||||
_issue_certificate(env, job, so, part, ctx)
|
||||
return True
|
||||
|
||||
|
||||
def _advance_to_invoice_posted(env, so, ctx):
|
||||
if not _advance_to_delivered(env, so, ctx, deliver_state="delivered"):
|
||||
return False
|
||||
inv = _create_invoice(env, so, ctx, post=True)
|
||||
return bool(inv and inv.state == "posted")
|
||||
|
||||
|
||||
def _advance_to_paid(env, so, ctx):
|
||||
if not _advance_to_delivered(env, so, ctx, deliver_state="delivered"):
|
||||
return False
|
||||
inv = _create_invoice(env, so, ctx, post=True)
|
||||
if not (inv and inv.state == "posted"):
|
||||
return False
|
||||
pmt = _register_payment(env, inv, ctx, validate=True)
|
||||
return bool(pmt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Per-order entry point #
|
||||
# ---------------------------------------------------------------------- #
|
||||
def _create_direct_order(env, partner, combos_for_partner, ctx,
|
||||
n_lines, advance_to, idx):
|
||||
"""Create one wizard-originated order, advance to target state.
|
||||
|
||||
Returns the SO record (or None on failure).
|
||||
"""
|
||||
so = _create_so_via_wizard(env, partner, combos_for_partner, n_lines, idx)
|
||||
if not so:
|
||||
return None
|
||||
|
||||
if advance_to == "confirmed":
|
||||
if not _advance_to_confirmed(env, so, ctx):
|
||||
return None
|
||||
elif advance_to == "in_progress_mid":
|
||||
if not _advance_to_in_progress_mid(env, so, ctx):
|
||||
return None
|
||||
elif advance_to == "delivered":
|
||||
# Job done + delivery scheduled (not yet delivered)
|
||||
if not _advance_to_delivered(env, so, ctx,
|
||||
deliver_state="scheduled"):
|
||||
return None
|
||||
elif advance_to == "invoiced":
|
||||
if not _advance_to_invoice_posted(env, so, ctx):
|
||||
return None
|
||||
elif advance_to == "paid":
|
||||
if not _advance_to_paid(env, so, ctx):
|
||||
return None
|
||||
|
||||
return so
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Main runner #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Plan: 11 orders distributed across 5 states.
|
||||
ORDER_PLAN = [
|
||||
("confirmed", 3),
|
||||
("in_progress_mid", 3),
|
||||
("delivered", 2),
|
||||
("invoiced", 2),
|
||||
("paid", 1),
|
||||
]
|
||||
|
||||
|
||||
def run(env):
|
||||
print("=" * 70)
|
||||
print("seed_direct_orders.py - estimator wizard path seeding")
|
||||
print("=" * 70)
|
||||
|
||||
combos = _build_combos(env)
|
||||
if not combos:
|
||||
print("ERROR: no parts with coating + recipe + partner. Cannot seed.")
|
||||
return
|
||||
print("Customer/part combos: %d" % len(combos))
|
||||
|
||||
# Group combos by partner so we can build multi-line orders for the
|
||||
# same customer (more realistic than one part per partner).
|
||||
by_partner = {}
|
||||
for (partner, part, coating) in combos:
|
||||
by_partner.setdefault(partner.id, []).append((partner, part, coating))
|
||||
partner_ids = list(by_partner.keys())
|
||||
random.shuffle(partner_ids)
|
||||
|
||||
operators = _operators(env)
|
||||
managers = _managers(env)
|
||||
employees = _employees(env)
|
||||
facility = _resolve_facility(env)
|
||||
payment_term = _resolve_payment_term(env)
|
||||
sales_journal, bank_journal = _resolve_journals(env)
|
||||
print("Operators: %d, Managers: %d, Employees: %d" % (
|
||||
len(operators), len(managers), len(employees)))
|
||||
print("Facility: %s, PaymentTerm: %s" % (
|
||||
facility.name if facility else "NONE",
|
||||
payment_term.name if payment_term else "NONE"))
|
||||
|
||||
if not (payment_term and sales_journal and bank_journal):
|
||||
print("ERROR: missing required masters; cannot proceed.")
|
||||
return
|
||||
|
||||
ctx = {
|
||||
"payment_term": payment_term,
|
||||
"sales_journal": sales_journal,
|
||||
"bank_journal": bank_journal,
|
||||
"operators": operators,
|
||||
"managers": managers,
|
||||
"employees": employees,
|
||||
"facility": facility,
|
||||
}
|
||||
|
||||
results = {state: 0 for (state, _n) in ORDER_PLAN}
|
||||
failures = []
|
||||
seq = 0
|
||||
partner_cursor = 0
|
||||
|
||||
for (state, count) in ORDER_PLAN:
|
||||
print()
|
||||
print("-- target state: %s (count %d) --" % (state, count))
|
||||
for _i in range(count):
|
||||
seq += 1
|
||||
# Round-robin partners to maximise variety
|
||||
partner_id = partner_ids[partner_cursor % len(partner_ids)]
|
||||
partner_cursor += 1
|
||||
partner = env["res.partner"].browse(partner_id)
|
||||
combos_for_partner = by_partner[partner_id]
|
||||
n_lines = random.randint(1, 3)
|
||||
|
||||
sp = "direct_order_%d" % seq
|
||||
env.cr.execute("SAVEPOINT %s" % sp)
|
||||
try:
|
||||
so = _create_direct_order(env, partner, combos_for_partner,
|
||||
ctx, n_lines, state, seq)
|
||||
if so:
|
||||
env.cr.execute("RELEASE SAVEPOINT %s" % sp)
|
||||
results[state] += 1
|
||||
print(" [%d] %s -> %s (state=%s, partner=%s)"
|
||||
% (seq, so.name, state, so.state, partner.name))
|
||||
else:
|
||||
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
|
||||
failures.append((seq, partner.name,
|
||||
"wizard returned no SO"))
|
||||
print(" [%d] FAILED for %s (no SO)"
|
||||
% (seq, partner.name))
|
||||
except Exception as e:
|
||||
try:
|
||||
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
|
||||
except Exception:
|
||||
pass
|
||||
failures.append((seq, partner.name, str(e)[:120]))
|
||||
print(" [%d] EXCEPTION for %s: %s"
|
||||
% (seq, partner.name, str(e)[:120]))
|
||||
env.cr.commit()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("DIRECT-ORDER SEED RESULTS")
|
||||
print("=" * 70)
|
||||
total = 0
|
||||
for state, n in results.items():
|
||||
print(" %-20s %d" % (state, n))
|
||||
total += n
|
||||
print(" %-20s %d" % ("TOTAL CREATED", total))
|
||||
if failures:
|
||||
print()
|
||||
print("FAILURES (%d):" % len(failures))
|
||||
for (seq, partner, reason) in failures:
|
||||
print(" #%d %-30s %s" % (seq, partner, reason))
|
||||
print()
|
||||
|
||||
|
||||
try:
|
||||
run(env)
|
||||
except NameError:
|
||||
print("Run inside odoo shell.")
|
||||
@@ -1,47 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Assigns existing fp.coating.config rows (with recipes) to parts that
|
||||
# don't have one yet. Round-robin distribution. Idempotent.
|
||||
#
|
||||
# Field name on fp.part.catalog is x_fc_default_coating_config_id.
|
||||
|
||||
COATING_FIELD = 'x_fc_default_coating_config_id'
|
||||
|
||||
|
||||
def run(env):
|
||||
print('=== Assigning coatings to bare parts ===')
|
||||
|
||||
Part = env['fp.part.catalog']
|
||||
Coating = env['fp.coating.config']
|
||||
|
||||
coatings_with_recipe = Coating.search([('recipe_id', '!=', False)])
|
||||
if not coatings_with_recipe:
|
||||
print(' No coatings with recipes available - abort')
|
||||
return
|
||||
print(f' Coatings with recipes: {len(coatings_with_recipe)}')
|
||||
|
||||
bare_parts = Part.search([(COATING_FIELD, '=', False)])
|
||||
print(f' Parts without coating: {len(bare_parts)}')
|
||||
|
||||
# Assign first 20 bare parts (or all if fewer)
|
||||
to_assign = bare_parts[:20]
|
||||
n = 0
|
||||
for i, part in enumerate(to_assign):
|
||||
coating = coatings_with_recipe[i % len(coatings_with_recipe)]
|
||||
part[COATING_FIELD] = coating.id
|
||||
n += 1
|
||||
print(f' {part.name!r} -> {coating.name!r}')
|
||||
|
||||
env.cr.commit()
|
||||
print()
|
||||
print(f'=== Done. Assigned coatings to {n} parts ===')
|
||||
final_count = Part.search_count([(COATING_FIELD, '!=', False)])
|
||||
print(f' Parts with coating now: {final_count}')
|
||||
|
||||
|
||||
try:
|
||||
run(env)
|
||||
except NameError:
|
||||
print('Run inside `odoo shell`.')
|
||||
@@ -1,98 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Seeds fp.work.centre rows that mirror the existing legacy
|
||||
# fusion.plating.work.center records (by code). Then backfills the
|
||||
# work_centre_id on every existing fp.job.step that has a
|
||||
# recipe_node_id whose underlying recipe operation points to a legacy
|
||||
# work centre we just created.
|
||||
#
|
||||
# Idempotent: skip rows that already exist by code.
|
||||
|
||||
KIND_KEYWORDS = [
|
||||
('bake', ['bake', 'oven']),
|
||||
('rack', ['rack']),
|
||||
('inspect',['inspect', 'qc', 'first', 'last']),
|
||||
('mask', ['mask']),
|
||||
('wet_line',['bath', 'plat', 'nickel', 'chrome', 'anodiz', 'rinse', 'tank', 'strip', 'etch']),
|
||||
]
|
||||
|
||||
|
||||
def _classify_kind(name, code):
|
||||
text = (name + ' ' + (code or '')).lower()
|
||||
for kind, keywords in KIND_KEYWORDS:
|
||||
if any(k in text for k in keywords):
|
||||
return kind
|
||||
return 'other'
|
||||
|
||||
|
||||
def run(env):
|
||||
print('=== Seeding fp.work.centre from legacy fusion.plating.work.center ===')
|
||||
|
||||
Native = env['fp.work.centre']
|
||||
Legacy = env['fusion.plating.work.center']
|
||||
|
||||
# 1. Create one fp.work.centre per legacy work centre (matched by code)
|
||||
legacy_centres = Legacy.search([])
|
||||
print(f' Legacy centres found: {len(legacy_centres)}')
|
||||
|
||||
created = 0
|
||||
for legacy in legacy_centres:
|
||||
if not legacy.code:
|
||||
print(f' SKIP (no code): {legacy.name}')
|
||||
continue
|
||||
existing = Native.search([('code', '=', legacy.code)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
kind = _classify_kind(legacy.name or '', legacy.code or '')
|
||||
vals = {
|
||||
'code': legacy.code,
|
||||
'name': legacy.name,
|
||||
'kind': kind,
|
||||
'active': True,
|
||||
'facility_id': legacy.facility_id.id if legacy.facility_id else False,
|
||||
}
|
||||
if hasattr(legacy, 'cost_per_hour'):
|
||||
vals['cost_per_hour'] = legacy.cost_per_hour
|
||||
Native.sudo().create(vals)
|
||||
created += 1
|
||||
|
||||
print(f' Native work centres created: {created}')
|
||||
print(f' Native total now: {Native.search_count([])}')
|
||||
|
||||
# 2. Backfill work_centre_id on existing fp.job.step rows
|
||||
print()
|
||||
print('=== Backfilling work_centre_id on existing fp.job.step rows ===')
|
||||
Step = env['fp.job.step']
|
||||
unbound = Step.search([('work_centre_id', '=', False), ('recipe_node_id', '!=', False)])
|
||||
print(f' Steps to backfill: {len(unbound)}')
|
||||
|
||||
bound = 0
|
||||
no_legacy = 0
|
||||
no_match = 0
|
||||
for step in unbound:
|
||||
legacy_wc = step.recipe_node_id.work_center_id
|
||||
if not legacy_wc or not legacy_wc.code:
|
||||
no_legacy += 1
|
||||
continue
|
||||
match = Native.search([('code', '=', legacy_wc.code)], limit=1)
|
||||
if not match:
|
||||
no_match += 1
|
||||
continue
|
||||
step.work_centre_id = match.id
|
||||
bound += 1
|
||||
|
||||
print(f' Bound: {bound}')
|
||||
print(f' Recipe op without legacy work centre: {no_legacy}')
|
||||
print(f' No matching native code: {no_match}')
|
||||
|
||||
env.cr.commit()
|
||||
print()
|
||||
print('=== Done ===')
|
||||
|
||||
|
||||
try:
|
||||
run(env)
|
||||
except NameError:
|
||||
print('Run inside `odoo shell`.')
|
||||
@@ -1,785 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# seed_workflow_states.py
|
||||
# =======================
|
||||
# Builds 7-8 sale orders in EACH lifecycle state from quotation through
|
||||
# paid invoice. The goal is a realistic dataset that exercises the full
|
||||
# pipeline so we can validate UI, reports, KPIs, and notifications.
|
||||
#
|
||||
# Workflow walkthrough findings (run end-to-end on entech 2026-04-25):
|
||||
#
|
||||
# STAGE AUTO-CREATES REQUIRED FIELDS
|
||||
# ----------------------------------------------------------------------
|
||||
# sale.order draft nothing partner_id, order_line
|
||||
# commitment_date is optional but
|
||||
# we always set it for realism
|
||||
# sale.order sent nothing write state="sent"
|
||||
# sale.order action_confirm fp.job (state=DRAFT, client_order_ref recommended;
|
||||
# step_count=0, recipe payment_term_id REQUIRED for
|
||||
# resolved from coating) downstream invoice posting
|
||||
# fp.job action_confirm portal_job_id state moves draft -> confirmed
|
||||
# fp.job._generate_steps_ step_ids populated must be called explicitly;
|
||||
# from_recipe() action_confirm does NOT do it
|
||||
# fp.job button_mark_done delivery (draft) + all steps must be done first;
|
||||
# cert (draft, type=coc) sets state=done, date_finished
|
||||
# fp.delivery scheduled -- scheduled_date, contact_name,
|
||||
# contact_phone, delivery_address_id,
|
||||
# x_fc_box_count_out,
|
||||
# assigned_driver_id (hr.employee)
|
||||
# fp.delivery delivered -- delivered_at
|
||||
# fp.certificate issued -- issue_date, issued_by_id,
|
||||
# entech_wo_number, customer_job_no,
|
||||
# po_number, quantity_shipped,
|
||||
# part_number,
|
||||
# thickness_reading_ids (3-5 rows)
|
||||
# account.move (draft) via so._create_invoices() invoice_date, invoice_date_due,
|
||||
# invoice_payment_term_id REQUIRED
|
||||
# or post fails
|
||||
# account.move action_post name=INV/YYYY/NNNNN invoice_date_due
|
||||
# account.payment.register account.payment partner_type, journal_id,
|
||||
# wizard (state=in_process) payment_method_line_id,
|
||||
# amount, payment_date
|
||||
# account.payment state=paid on payment; ALL upstream prerequisites
|
||||
# action_validate invoice payment_state= above
|
||||
# in_payment (not paid - (Odoo 19 design - paid state
|
||||
# that requires bank requires bank reconciliation)
|
||||
# statement reconciliation)
|
||||
#
|
||||
# Strategy:
|
||||
# - Each order is wrapped in its OWN savepoint. If anything fails, we
|
||||
# ROLLBACK that savepoint and continue to the next order.
|
||||
# - We commit at the end of each stage so partial successes still land.
|
||||
# - Customer/part variety: spread across 10+ partners, cycle through all
|
||||
# parts that have coatings. Operators round-robin across the 20.
|
||||
# - Date variety: past 60 days for delivered/paid; future 1-30 days for
|
||||
# active jobs.
|
||||
#
|
||||
# Usage (entech): see scripts/README.md.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
import logging
|
||||
|
||||
random.seed(2026)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Stage targets - these are the seed counts per stage requested by spec.
|
||||
# Adjust here if you want fewer/more.
|
||||
TARGETS = {
|
||||
"so_draft": 8,
|
||||
"so_sent": 7,
|
||||
"job_confirmed_no_steps_started": 8,
|
||||
"job_in_progress_early": 7,
|
||||
"job_in_progress_mid": 8,
|
||||
"job_on_hold": 5,
|
||||
"job_done_delivery_draft": 7,
|
||||
"delivery_scheduled": 7,
|
||||
"delivery_en_route": 5,
|
||||
"delivery_delivered": 8,
|
||||
"invoice_draft": 7,
|
||||
"invoice_posted": 7,
|
||||
"paid": 7,
|
||||
}
|
||||
|
||||
|
||||
def _pick_combo(combos, idx):
|
||||
return combos[idx % len(combos)]
|
||||
|
||||
|
||||
def _build_combos(env):
|
||||
"""List of (partner, part, coating) for SO-confirm seeding."""
|
||||
combos = []
|
||||
parts = env["fp.part.catalog"].search([
|
||||
("x_fc_default_coating_config_id", "!=", False),
|
||||
("x_fc_default_coating_config_id.recipe_id", "!=", False),
|
||||
("partner_id", "!=", False),
|
||||
])
|
||||
for p in parts:
|
||||
combos.append((p.partner_id, p, p.x_fc_default_coating_config_id))
|
||||
random.shuffle(combos)
|
||||
return combos
|
||||
|
||||
|
||||
def _operators(env):
|
||||
g = env.ref("fusion_plating.group_fusion_plating_operator",
|
||||
raise_if_not_found=False)
|
||||
if not g:
|
||||
return env["res.users"]
|
||||
return env["res.users"].search([("all_group_ids", "in", g.id)])
|
||||
|
||||
|
||||
def _managers(env):
|
||||
g = env.ref("fusion_plating.group_fusion_plating_manager",
|
||||
raise_if_not_found=False)
|
||||
if not g:
|
||||
return env["res.users"]
|
||||
return env["res.users"].search([("all_group_ids", "in", g.id)])
|
||||
|
||||
|
||||
def _employees(env):
|
||||
return env["hr.employee"].search([])
|
||||
|
||||
|
||||
def _resolve_product(env):
|
||||
"""Find the right plating-service product to use for SO lines."""
|
||||
p = env["product.product"].search(
|
||||
[("name", "=", "Plating Service")], limit=1)
|
||||
if p:
|
||||
return p
|
||||
p = env["product.product"].search(
|
||||
[("sale_ok", "=", True), ("type", "=", "service")], limit=1)
|
||||
if p:
|
||||
return p
|
||||
return env["product.product"].search([("sale_ok", "=", True)], limit=1)
|
||||
|
||||
|
||||
def _resolve_payment_term(env):
|
||||
"""Net 30 by default."""
|
||||
pt = env["account.payment.term"].search(
|
||||
[("name", "=", "30 Days")], limit=1)
|
||||
if not pt:
|
||||
pt = env["account.payment.term"].search([], limit=1)
|
||||
return pt
|
||||
|
||||
|
||||
def _resolve_journals(env):
|
||||
sales = env["account.journal"].search([("type", "=", "sale")], limit=1)
|
||||
bank = env["account.journal"].search([("type", "=", "bank")], limit=1)
|
||||
return sales, bank
|
||||
|
||||
|
||||
def _resolve_facility(env):
|
||||
return env["fusion.plating.facility"].search([], limit=1)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
def _make_so(env, partner, part, coating, qty, price, ctx):
|
||||
"""Create a draft SO with one detailed plating line."""
|
||||
SOL_fields = env["sale.order.line"]._fields
|
||||
line_vals = {
|
||||
"product_id": ctx["product"].id,
|
||||
"product_uom_qty": qty,
|
||||
"price_unit": price,
|
||||
"name": "ENP plating service: %s rev %s" % (
|
||||
part.part_number or part.name, part.revision or "A"),
|
||||
}
|
||||
if "x_fc_part_catalog_id" in SOL_fields:
|
||||
line_vals["x_fc_part_catalog_id"] = part.id
|
||||
if "x_fc_coating_config_id" in SOL_fields:
|
||||
line_vals["x_fc_coating_config_id"] = coating.id
|
||||
if "x_fc_internal_description" in SOL_fields:
|
||||
line_vals["x_fc_internal_description"] = (
|
||||
"Internal: %s, %.1f mils target, mil-spec" % (
|
||||
coating.name, coating.thickness_max or 1.0))
|
||||
|
||||
so_vals = {
|
||||
"partner_id": partner.id,
|
||||
"partner_invoice_id": partner.id,
|
||||
"partner_shipping_id": partner.id,
|
||||
"client_order_ref": "CUST-PO-%05d" % random.randint(10000, 99999),
|
||||
"commitment_date": datetime.now() + timedelta(
|
||||
days=random.randint(7, 30)),
|
||||
"validity_date": (datetime.now() + timedelta(days=30)).date(),
|
||||
"payment_term_id": ctx["payment_term"].id,
|
||||
"order_line": [(0, 0, line_vals)],
|
||||
}
|
||||
SO_fields = env["sale.order"]._fields
|
||||
if "x_fc_po_number" in SO_fields:
|
||||
so_vals["x_fc_po_number"] = "PO-%05d" % random.randint(10000, 99999)
|
||||
if "x_fc_part_catalog_id" in SO_fields:
|
||||
so_vals["x_fc_part_catalog_id"] = part.id
|
||||
if "x_fc_coating_config_id" in SO_fields:
|
||||
so_vals["x_fc_coating_config_id"] = coating.id
|
||||
if "x_fc_internal_note" in SO_fields:
|
||||
so_vals["x_fc_internal_note"] = (
|
||||
"<p>Customer is OK with rush production if capacity allows.</p>")
|
||||
if "x_fc_external_note" in SO_fields:
|
||||
so_vals["x_fc_external_note"] = (
|
||||
"<p>Please confirm receipt of parts before processing.</p>")
|
||||
return env["sale.order"].sudo().create(so_vals)
|
||||
|
||||
|
||||
def _populate_job(env, job, ctx, fill_facility=True, fill_manager=True):
|
||||
"""Fill out fp.job extra fields after creation."""
|
||||
if not job:
|
||||
return
|
||||
vals = {}
|
||||
if fill_facility and ctx["facility"] and not job.facility_id:
|
||||
vals["facility_id"] = ctx["facility"].id
|
||||
if fill_manager and ctx["managers"] and not job.manager_id:
|
||||
vals["manager_id"] = random.choice(ctx["managers"]).id
|
||||
if not job.priority or job.priority == "normal":
|
||||
vals["priority"] = random.choices(
|
||||
["low", "normal", "high", "rush"],
|
||||
weights=[10, 70, 15, 5],
|
||||
)[0]
|
||||
if vals:
|
||||
job.write(vals)
|
||||
|
||||
|
||||
def _ensure_steps(env, job):
|
||||
"""Force step generation. action_confirm doesn t do this on its own."""
|
||||
if not job:
|
||||
return
|
||||
if not job.recipe_id:
|
||||
return
|
||||
if job.step_ids:
|
||||
return
|
||||
try:
|
||||
job._generate_steps_from_recipe()
|
||||
except Exception as e:
|
||||
_logger.warning("Job %s step gen failed: %s", job.name, e)
|
||||
|
||||
|
||||
def _assign_step_users(env, job, ctx, n_done=0, current_idx=None):
|
||||
"""Assign operators to all steps; mark first n_done as done, and
|
||||
optionally one step at current_idx as in_progress.
|
||||
"""
|
||||
operators = ctx["operators"]
|
||||
steps = job.step_ids.sorted("sequence")
|
||||
if not steps:
|
||||
return
|
||||
for s in steps:
|
||||
if operators and not s.assigned_user_id:
|
||||
s.assigned_user_id = operators[
|
||||
random.randrange(len(operators))]
|
||||
|
||||
base = datetime.now() - timedelta(hours=len(steps) * 2)
|
||||
for i, s in enumerate(steps[:n_done]):
|
||||
start = base + timedelta(hours=i * 2)
|
||||
finish = start + timedelta(minutes=random.randint(20, 90))
|
||||
s.write({
|
||||
"state": "done",
|
||||
"date_started": start,
|
||||
"date_finished": finish,
|
||||
"duration_actual": (finish - start).total_seconds() / 60.0,
|
||||
"started_by_user_id": s.assigned_user_id.id if s.assigned_user_id else env.user.id,
|
||||
"finished_by_user_id": s.assigned_user_id.id if s.assigned_user_id else env.user.id,
|
||||
})
|
||||
env["fp.job.step.timelog"].sudo().create({
|
||||
"step_id": s.id,
|
||||
"user_id": s.assigned_user_id.id if s.assigned_user_id else env.user.id,
|
||||
"date_started": start,
|
||||
"date_finished": finish,
|
||||
"duration_minutes": (finish - start).total_seconds() / 60.0,
|
||||
})
|
||||
|
||||
if current_idx is not None and current_idx < len(steps):
|
||||
cur = steps[current_idx]
|
||||
if cur.state != "done":
|
||||
start = datetime.now() - timedelta(
|
||||
minutes=random.randint(5, 90))
|
||||
cur.write({
|
||||
"state": "in_progress",
|
||||
"date_started": start,
|
||||
"started_by_user_id": cur.assigned_user_id.id if cur.assigned_user_id else env.user.id,
|
||||
})
|
||||
env["fp.job.step.timelog"].sudo().create({
|
||||
"step_id": cur.id,
|
||||
"user_id": cur.assigned_user_id.id if cur.assigned_user_id else env.user.id,
|
||||
"date_started": start,
|
||||
})
|
||||
|
||||
if current_idx is not None and current_idx + 1 < len(steps):
|
||||
next_step = steps[current_idx + 1]
|
||||
if next_step.state == "pending":
|
||||
next_step.write({"state": "ready"})
|
||||
|
||||
|
||||
def _fill_step_realistic_data(env, job):
|
||||
for s in job.step_ids:
|
||||
kind = s.kind
|
||||
if kind == "bake":
|
||||
if not s.bake_setpoint_temp:
|
||||
s.bake_setpoint_temp = random.choice([375.0, 400.0, 425.0])
|
||||
if not s.bake_actual_duration and s.state == "done":
|
||||
s.bake_actual_duration = random.uniform(3.5, 4.5)
|
||||
elif kind == "wet":
|
||||
if not s.thickness_target:
|
||||
s.thickness_target = round(random.uniform(0.5, 2.0), 2)
|
||||
s.thickness_uom = "mil"
|
||||
|
||||
|
||||
def _make_delivery_full(env, delivery, partner, ctx, state, scheduled_offset_days=1):
|
||||
"""Fill delivery with realistic logistics fields and advance state."""
|
||||
if not delivery:
|
||||
return
|
||||
employees = ctx["employees"]
|
||||
facility = ctx["facility"]
|
||||
vals = {
|
||||
"delivery_address_id": partner.id,
|
||||
"contact_name": partner.name,
|
||||
"contact_phone": partner.phone or "555-%04d" % random.randint(1000, 9999),
|
||||
"scheduled_date": datetime.now() + timedelta(days=scheduled_offset_days),
|
||||
}
|
||||
if "x_fc_box_count_out" in delivery._fields:
|
||||
vals["x_fc_box_count_out"] = random.randint(1, 5)
|
||||
if employees and "assigned_driver_id" in delivery._fields:
|
||||
vals["assigned_driver_id"] = employees[
|
||||
random.randrange(len(employees))].id
|
||||
if facility and "source_facility_id" in delivery._fields:
|
||||
vals["source_facility_id"] = facility.id
|
||||
if "notes" in delivery._fields:
|
||||
vals["notes"] = (
|
||||
"<p>Standard delivery - handle with care, parts plated to spec.</p>")
|
||||
delivery.write(vals)
|
||||
delivery.write({"state": state})
|
||||
if state == "delivered":
|
||||
delivery.write({"delivered_at": datetime.now() - timedelta(
|
||||
hours=random.randint(1, 48))})
|
||||
|
||||
|
||||
def _issue_certificate(env, job, so, part, ctx):
|
||||
cert = env["fp.certificate"].search(
|
||||
[("x_fc_job_id", "=", job.id)], limit=1)
|
||||
if not cert:
|
||||
cert = env["fp.certificate"].sudo().create({
|
||||
"partner_id": job.partner_id.id,
|
||||
"certificate_type": "coc",
|
||||
"state": "draft",
|
||||
"x_fc_job_id": job.id,
|
||||
"sale_order_id": so.id if so else False,
|
||||
})
|
||||
vals = {
|
||||
"state": "issued",
|
||||
"issue_date": datetime.now().date(),
|
||||
"issued_by_id": env.user.id,
|
||||
"entech_wo_number": job.name,
|
||||
"customer_job_no": so.client_order_ref if so else "",
|
||||
"po_number": so.x_fc_po_number if so and "x_fc_po_number" in so._fields else "",
|
||||
"quantity_shipped": int(job.qty or 1),
|
||||
"part_number": part.part_number or part.name,
|
||||
"process_description": "Electroless Nickel Plating, MIL-C-26074",
|
||||
}
|
||||
if "spec_min_mils" in cert._fields and part.x_fc_default_coating_config_id:
|
||||
c = part.x_fc_default_coating_config_id
|
||||
if c.thickness_min:
|
||||
vals["spec_min_mils"] = c.thickness_min
|
||||
if c.thickness_max:
|
||||
vals["spec_max_mils"] = c.thickness_max
|
||||
vals["spec_reference"] = c.spec_reference or "AMS-2404"
|
||||
cert.write(vals)
|
||||
for i in range(5):
|
||||
env["fp.thickness.reading"].sudo().create({
|
||||
"certificate_id": cert.id,
|
||||
"reading_number": i + 1,
|
||||
"nip_mils": round(random.uniform(0.95, 1.15), 3),
|
||||
"ni_percent": round(random.uniform(88.0, 92.0), 2),
|
||||
"p_percent": round(random.uniform(8.0, 12.0), 2),
|
||||
"position_label": "Pos %d" % (i + 1),
|
||||
"reading_datetime": datetime.now() - timedelta(
|
||||
minutes=30 - i * 5),
|
||||
"operator_id": env.user.id,
|
||||
"x_fc_job_id": job.id,
|
||||
"equipment_model": "Fischerscope X-Ray XDV-SD",
|
||||
"calibration_std_ref": "CAL-2026-04-01",
|
||||
})
|
||||
return cert
|
||||
|
||||
|
||||
def _create_quality_hold(env, job, ctx):
|
||||
if "fusion.plating.quality.hold" not in env:
|
||||
return
|
||||
Hold = env["fusion.plating.quality.hold"].sudo()
|
||||
steps = job.step_ids.sorted("sequence")
|
||||
affected_step = None
|
||||
for s in steps:
|
||||
if s.state in ("paused", "in_progress"):
|
||||
affected_step = s
|
||||
break
|
||||
vals = {
|
||||
"hold_reason": random.choice(
|
||||
["out_of_spec", "damaged", "contamination", "process_deviation"]),
|
||||
"qty_on_hold": max(1, int((job.qty or 1) // 4)),
|
||||
"qty_original": int(job.qty or 1),
|
||||
"description": "Sample inspection caught dimensional drift on first-piece. Holding for engineering review.",
|
||||
"state": "on_hold",
|
||||
}
|
||||
if "x_fc_job_id" in Hold._fields:
|
||||
vals["x_fc_job_id"] = job.id
|
||||
if affected_step and "x_fc_step_id" in Hold._fields:
|
||||
vals["x_fc_step_id"] = affected_step.id
|
||||
if ctx["facility"] and "facility_id" in Hold._fields:
|
||||
vals["facility_id"] = ctx["facility"].id
|
||||
if "operator_id" in Hold._fields and ctx["operators"]:
|
||||
vals["operator_id"] = random.choice(ctx["operators"]).id
|
||||
if "part_ref" in Hold._fields:
|
||||
vals["part_ref"] = job.part_catalog_id.part_number if job.part_catalog_id else ""
|
||||
try:
|
||||
Hold.create(vals)
|
||||
except Exception as e:
|
||||
_logger.warning("Hold create failed for %s: %s", job.name, e)
|
||||
|
||||
|
||||
def _create_invoice(env, so, ctx, post=False):
|
||||
inv_recordset = so._create_invoices()
|
||||
if not inv_recordset:
|
||||
return env["account.move"]
|
||||
inv = inv_recordset[0] if hasattr(inv_recordset, "ids") else env["account.move"].browse(inv_recordset)
|
||||
inv_vals = {
|
||||
"invoice_date": (datetime.now() - timedelta(
|
||||
days=random.randint(0, 5))).date(),
|
||||
"invoice_date_due": (datetime.now() + timedelta(
|
||||
days=random.randint(15, 30))).date(),
|
||||
}
|
||||
if not inv.invoice_payment_term_id:
|
||||
inv_vals["invoice_payment_term_id"] = ctx["payment_term"].id
|
||||
inv.write(inv_vals)
|
||||
if post:
|
||||
inv.action_post()
|
||||
return inv
|
||||
|
||||
|
||||
def _register_payment(env, inv, ctx, validate=True):
|
||||
bank = ctx["bank_journal"]
|
||||
pml = bank.inbound_payment_method_line_ids[:1]
|
||||
wizard = env["account.payment.register"].with_context(
|
||||
active_model="account.move",
|
||||
active_ids=inv.ids,
|
||||
).create({
|
||||
"amount": inv.amount_total,
|
||||
"journal_id": bank.id,
|
||||
"payment_method_line_id": pml.id if pml else False,
|
||||
"payment_date": (datetime.now() - timedelta(
|
||||
days=random.randint(0, 7))).date(),
|
||||
})
|
||||
wizard.action_create_payments()
|
||||
pmt = env["account.payment"].search(
|
||||
[("partner_id", "=", inv.partner_id.id),
|
||||
("amount", "=", inv.amount_total)],
|
||||
order="id desc", limit=1)
|
||||
if pmt and validate:
|
||||
try:
|
||||
pmt.action_validate()
|
||||
except Exception as e:
|
||||
_logger.warning("Payment validate failed: %s", e)
|
||||
try:
|
||||
pmt.write({"state": "paid"})
|
||||
except Exception as e2:
|
||||
_logger.warning("Payment direct write paid failed: %s", e2)
|
||||
return pmt
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
def _stage(env, label, fn, n, combos, idx_holder, ctx, results):
|
||||
print("-- %s (target %d) --" % (label, n))
|
||||
success = 0
|
||||
sp_safe = "".join(c if c.isalnum() else "_" for c in label).strip("_")
|
||||
sp_label = "seed_" + sp_safe
|
||||
for i in range(n):
|
||||
partner, part, coating = _pick_combo(combos, idx_holder[0])
|
||||
idx_holder[0] += 1
|
||||
sp = "%s_%d" % (sp_label, i)
|
||||
env.cr.execute("SAVEPOINT %s" % sp)
|
||||
try:
|
||||
if fn(env, partner, part, coating, ctx):
|
||||
env.cr.execute("RELEASE SAVEPOINT %s" % sp)
|
||||
success += 1
|
||||
else:
|
||||
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
|
||||
except Exception as e:
|
||||
print(" WARN [%s #%d]: %s" % (label, i, e))
|
||||
try:
|
||||
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
|
||||
except Exception:
|
||||
pass
|
||||
results[label] = success
|
||||
print(" -> %d/%d succeeded" % (success, n))
|
||||
|
||||
|
||||
# -------------------- Stage handlers --------------------
|
||||
def stage_so_draft(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([5, 10, 25, 50, 100]),
|
||||
price=random.uniform(75.0, 350.0), ctx=ctx)
|
||||
return bool(so)
|
||||
|
||||
|
||||
def stage_so_sent(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([10, 25, 50, 100]),
|
||||
price=random.uniform(75.0, 350.0), ctx=ctx)
|
||||
so.write({"state": "sent"})
|
||||
return True
|
||||
|
||||
|
||||
def stage_job_confirmed(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([10, 25, 50]),
|
||||
price=random.uniform(100.0, 350.0), ctx=ctx)
|
||||
so.action_confirm()
|
||||
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
|
||||
if not job:
|
||||
return False
|
||||
if job.state == "draft":
|
||||
job.action_confirm()
|
||||
_ensure_steps(env, job)
|
||||
_populate_job(env, job, ctx)
|
||||
_assign_step_users(env, job, ctx, n_done=0, current_idx=None)
|
||||
_fill_step_realistic_data(env, job)
|
||||
return True
|
||||
|
||||
|
||||
def stage_job_in_progress_early(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([10, 25, 50]),
|
||||
price=random.uniform(100.0, 300.0), ctx=ctx)
|
||||
so.action_confirm()
|
||||
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
|
||||
if not job:
|
||||
return False
|
||||
if job.state == "draft":
|
||||
job.action_confirm()
|
||||
_ensure_steps(env, job)
|
||||
_populate_job(env, job, ctx)
|
||||
n_done = random.choice([1, 2])
|
||||
_assign_step_users(env, job, ctx, n_done=n_done, current_idx=n_done)
|
||||
_fill_step_realistic_data(env, job)
|
||||
job.write({
|
||||
"state": "in_progress",
|
||||
"date_started": datetime.now() - timedelta(
|
||||
days=random.randint(1, 4)),
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def stage_job_in_progress_mid(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([10, 25, 50]),
|
||||
price=random.uniform(100.0, 300.0), ctx=ctx)
|
||||
so.action_confirm()
|
||||
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
|
||||
if not job:
|
||||
return False
|
||||
if job.state == "draft":
|
||||
job.action_confirm()
|
||||
_ensure_steps(env, job)
|
||||
_populate_job(env, job, ctx)
|
||||
total = len(job.step_ids)
|
||||
n_done = max(1, total // 2) if total else 0
|
||||
_assign_step_users(env, job, ctx, n_done=n_done, current_idx=n_done)
|
||||
_fill_step_realistic_data(env, job)
|
||||
job.write({
|
||||
"state": "in_progress",
|
||||
"date_started": datetime.now() - timedelta(
|
||||
days=random.randint(2, 7)),
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def stage_job_on_hold(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([10, 25, 50]),
|
||||
price=random.uniform(100.0, 300.0), ctx=ctx)
|
||||
so.action_confirm()
|
||||
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
|
||||
if not job:
|
||||
return False
|
||||
if job.state == "draft":
|
||||
job.action_confirm()
|
||||
_ensure_steps(env, job)
|
||||
_populate_job(env, job, ctx)
|
||||
total = len(job.step_ids)
|
||||
n_done = min(2, max(1, total // 3)) if total else 0
|
||||
_assign_step_users(env, job, ctx, n_done=n_done, current_idx=n_done)
|
||||
if total > n_done:
|
||||
cur = job.step_ids.sorted("sequence")[n_done]
|
||||
cur.write({"state": "paused"})
|
||||
_fill_step_realistic_data(env, job)
|
||||
job.write({"state": "on_hold"})
|
||||
_create_quality_hold(env, job, ctx)
|
||||
return True
|
||||
|
||||
|
||||
def stage_job_done_delivery_draft(env, partner, part, coating, ctx):
|
||||
so = _make_so(env, partner, part, coating,
|
||||
qty=random.choice([5, 10, 25]),
|
||||
price=random.uniform(80.0, 250.0), ctx=ctx)
|
||||
so.action_confirm()
|
||||
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
|
||||
if not job:
|
||||
return False
|
||||
if job.state == "draft":
|
||||
job.action_confirm()
|
||||
_ensure_steps(env, job)
|
||||
_populate_job(env, job, ctx)
|
||||
_assign_step_users(env, job, ctx,
|
||||
n_done=len(job.step_ids),
|
||||
current_idx=None)
|
||||
_fill_step_realistic_data(env, job)
|
||||
job.write({
|
||||
"state": "in_progress",
|
||||
"date_started": datetime.now() - timedelta(
|
||||
days=random.randint(3, 10)),
|
||||
})
|
||||
job.button_mark_done()
|
||||
return True
|
||||
|
||||
|
||||
def stage_delivery_scheduled(env, partner, part, coating, ctx):
|
||||
if not stage_job_done_delivery_draft(env, partner, part, coating, ctx):
|
||||
return False
|
||||
job = env["fp.job"].search(
|
||||
[("partner_id", "=", partner.id)],
|
||||
order="id desc", limit=1)
|
||||
if not job or not job.delivery_id:
|
||||
return False
|
||||
_make_delivery_full(env, job.delivery_id, partner, ctx,
|
||||
state="scheduled",
|
||||
scheduled_offset_days=random.randint(1, 5))
|
||||
return True
|
||||
|
||||
|
||||
def stage_delivery_en_route(env, partner, part, coating, ctx):
|
||||
if not stage_job_done_delivery_draft(env, partner, part, coating, ctx):
|
||||
return False
|
||||
job = env["fp.job"].search(
|
||||
[("partner_id", "=", partner.id)],
|
||||
order="id desc", limit=1)
|
||||
if not job or not job.delivery_id:
|
||||
return False
|
||||
_make_delivery_full(env, job.delivery_id, partner, ctx,
|
||||
state="en_route",
|
||||
scheduled_offset_days=0)
|
||||
return True
|
||||
|
||||
|
||||
def stage_delivery_delivered(env, partner, part, coating, ctx):
|
||||
if not stage_job_done_delivery_draft(env, partner, part, coating, ctx):
|
||||
return False
|
||||
job = env["fp.job"].search(
|
||||
[("partner_id", "=", partner.id)],
|
||||
order="id desc", limit=1)
|
||||
if not job or not job.delivery_id:
|
||||
return False
|
||||
_make_delivery_full(env, job.delivery_id, partner, ctx,
|
||||
state="delivered",
|
||||
scheduled_offset_days=-2)
|
||||
so = job.sale_order_id
|
||||
_issue_certificate(env, job, so, part, ctx)
|
||||
return True
|
||||
|
||||
|
||||
def stage_invoice_draft(env, partner, part, coating, ctx):
|
||||
if not stage_delivery_delivered(env, partner, part, coating, ctx):
|
||||
return False
|
||||
job = env["fp.job"].search(
|
||||
[("partner_id", "=", partner.id)],
|
||||
order="id desc", limit=1)
|
||||
so = job.sale_order_id
|
||||
if not so:
|
||||
return False
|
||||
inv = _create_invoice(env, so, ctx, post=False)
|
||||
return bool(inv)
|
||||
|
||||
|
||||
def stage_invoice_posted(env, partner, part, coating, ctx):
|
||||
if not stage_delivery_delivered(env, partner, part, coating, ctx):
|
||||
return False
|
||||
job = env["fp.job"].search(
|
||||
[("partner_id", "=", partner.id)],
|
||||
order="id desc", limit=1)
|
||||
so = job.sale_order_id
|
||||
if not so:
|
||||
return False
|
||||
inv = _create_invoice(env, so, ctx, post=True)
|
||||
return inv and inv.state == "posted"
|
||||
|
||||
|
||||
def stage_paid(env, partner, part, coating, ctx):
|
||||
if not stage_delivery_delivered(env, partner, part, coating, ctx):
|
||||
return False
|
||||
job = env["fp.job"].search(
|
||||
[("partner_id", "=", partner.id)],
|
||||
order="id desc", limit=1)
|
||||
so = job.sale_order_id
|
||||
if not so:
|
||||
return False
|
||||
inv = _create_invoice(env, so, ctx, post=True)
|
||||
if not inv or inv.state != "posted":
|
||||
return False
|
||||
pmt = _register_payment(env, inv, ctx, validate=True)
|
||||
return bool(pmt)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
def run(env):
|
||||
print("=" * 70)
|
||||
print("seed_workflow_states.py - full pipeline seeding")
|
||||
print("=" * 70)
|
||||
|
||||
combos = _build_combos(env)
|
||||
print("Customer/part combos: %d" % len(combos))
|
||||
if not combos:
|
||||
print("ERROR: no parts with coating + recipe + partner. Cannot seed.")
|
||||
return
|
||||
operators = _operators(env)
|
||||
managers = _managers(env)
|
||||
employees = _employees(env)
|
||||
facility = _resolve_facility(env)
|
||||
product = _resolve_product(env)
|
||||
payment_term = _resolve_payment_term(env)
|
||||
sales_journal, bank_journal = _resolve_journals(env)
|
||||
print("Operators: %d, Managers: %d, Employees: %d" % (
|
||||
len(operators), len(managers), len(employees)))
|
||||
print("Facility: %s, Product: %s, PaymentTerm: %s" % (
|
||||
facility.name if facility else "NONE",
|
||||
product.name if product else "NONE",
|
||||
payment_term.name if payment_term else "NONE"))
|
||||
print("Sales journal: %s, Bank journal: %s" % (
|
||||
sales_journal.name if sales_journal else "NONE",
|
||||
bank_journal.name if bank_journal else "NONE"))
|
||||
|
||||
if not (product and payment_term and sales_journal and bank_journal):
|
||||
print("ERROR: missing required masters; cannot proceed.")
|
||||
return
|
||||
|
||||
ctx = {
|
||||
"product": product,
|
||||
"payment_term": payment_term,
|
||||
"sales_journal": sales_journal,
|
||||
"bank_journal": bank_journal,
|
||||
"operators": operators,
|
||||
"managers": managers,
|
||||
"employees": employees,
|
||||
"facility": facility,
|
||||
}
|
||||
|
||||
idx_holder = [0]
|
||||
results = {}
|
||||
|
||||
stages = [
|
||||
("Quotation (sale.order draft)", stage_so_draft, TARGETS["so_draft"]),
|
||||
("Quote Sent (sale.order sent)", stage_so_sent, TARGETS["so_sent"]),
|
||||
("Order Confirmed Job Just Started", stage_job_confirmed, TARGETS["job_confirmed_no_steps_started"]),
|
||||
("Job In Progress Early", stage_job_in_progress_early, TARGETS["job_in_progress_early"]),
|
||||
("Job In Progress Mid", stage_job_in_progress_mid, TARGETS["job_in_progress_mid"]),
|
||||
("Job On Hold", stage_job_on_hold, TARGETS["job_on_hold"]),
|
||||
("Job Done Delivery Draft", stage_job_done_delivery_draft, TARGETS["job_done_delivery_draft"]),
|
||||
("Delivery Scheduled", stage_delivery_scheduled, TARGETS["delivery_scheduled"]),
|
||||
("Delivery En Route", stage_delivery_en_route, TARGETS["delivery_en_route"]),
|
||||
("Delivered", stage_delivery_delivered, TARGETS["delivery_delivered"]),
|
||||
("Invoice Draft", stage_invoice_draft, TARGETS["invoice_draft"]),
|
||||
("Invoice Posted", stage_invoice_posted, TARGETS["invoice_posted"]),
|
||||
("Paid", stage_paid, TARGETS["paid"]),
|
||||
]
|
||||
|
||||
for label, fn, n in stages:
|
||||
_stage(env, label, fn, n, combos, idx_holder, ctx, results)
|
||||
env.cr.commit()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("SEED RESULTS")
|
||||
print("=" * 70)
|
||||
for label, count in results.items():
|
||||
print(" %-45s %d" % (label, count))
|
||||
print()
|
||||
|
||||
|
||||
try:
|
||||
run(env)
|
||||
except NameError:
|
||||
print("Run inside odoo shell.")
|
||||
@@ -1,28 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_job_node_override,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fp_job_node_override,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_node_override,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_step_move_wiz_op,fp.job.step.move.wiz.operator,model_fp_job_step_move_wizard,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_job_step_move_wiz_sup,fp.job.step.move.wiz.supervisor,model_fp_job_step_move_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_job_step_move_wiz_mgr,fp.job.step.move.wiz.manager,model_fp_job_step_move_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_step_move_wiz_in_op,fp.job.step.move.wiz.in.operator,model_fp_job_step_move_wizard_input,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_job_step_move_wiz_in_sup,fp.job.step.move.wiz.in.supervisor,model_fp_job_step_move_wizard_input,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_job_step_move_wiz_in_mgr,fp.job.step.move.wiz.in.manager,model_fp_job_step_move_wizard_input,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_step_input_wiz_op,fp.job.step.input.wiz.operator,model_fp_job_step_input_wizard,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_job_step_input_wiz_sup,fp.job.step.input.wiz.supervisor,model_fp_job_step_input_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_job_step_input_wiz_mgr,fp.job.step.input.wiz.manager,model_fp_job_step_input_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_step_input_wiz_l_op,fp.job.step.input.wiz.l.operator,model_fp_job_step_input_wizard_line,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_job_step_input_wiz_l_sup,fp.job.step.input.wiz.l.supervisor,model_fp_job_step_input_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_step_input_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_sup,fp.cert.issue.wiz.supervisor,model_fp_cert_issue_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_cert_issue_wiz_mgr,fp.cert.issue.wiz.manager,model_fp_cert_issue_wizard,fusion_plating.group_fp_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_fp_shop_manager_v2,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_fp_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_fp_shop_manager_v2,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_fp_manager,1,1,1,1
|
||||
|
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="0">
|
||||
<!-- Hidden group used to gate legacy MO/WO menus that have been
|
||||
replaced by fp.job equivalents. Nobody is in this group by
|
||||
default, so the legacy menus are invisible to all users. An
|
||||
admin can manually add themselves via Settings > Users if
|
||||
they need to access historical MO/WO data. -->
|
||||
<record id="group_fusion_plating_legacy_menus" model="res.groups">
|
||||
<field name="name">[DEPRECATED] Plating Legacy Menus</field>
|
||||
<field name="comment">Internal group to hide legacy MO/WO menus that have been replaced by the native fp.job model. Add a user to this group only if they need to navigate historical mrp.production / mrp.workorder records directly.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,86 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Fusion Plating - Issue Certs wizard: auto-edit the first incomplete row
|
||||
* on wizard mount.
|
||||
*
|
||||
* Background: Odoo's editable o2m list keeps non-selected rows in display
|
||||
* mode, which hides the binary widget's "↑ Upload your file" link until
|
||||
* the operator clicks the row. Operators reported the wizard as broken
|
||||
* because the file field appeared empty.
|
||||
*
|
||||
* Fix without fighting CSS: when the wizard's list renders, simulate a
|
||||
* click on the first row that still needs thickness data. The native
|
||||
* binary widget then renders in edit mode and the upload link is
|
||||
* immediately visible - no theme override needed.
|
||||
*
|
||||
* Scoped to `.o_fp_cert_issue_wizard_form` (the wizard form's
|
||||
* css_class) so this DOM-poke doesn't fire on other editable o2m lists.
|
||||
*/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { FormController } from "@web/views/form/form_controller";
|
||||
import { onMounted } from "@odoo/owl";
|
||||
|
||||
|
||||
export class FpCertIssueWizardFormController extends FormController {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
onMounted(() => {
|
||||
// Defer one tick so the o2m list has finished its first paint.
|
||||
requestAnimationFrame(() => this._fpAutoEditFirstRow());
|
||||
});
|
||||
}
|
||||
|
||||
_fpAutoEditFirstRow() {
|
||||
// Only fire on the cert-issue wizard. Other form views that use
|
||||
// the same FormController class get the default behaviour.
|
||||
const root = this.rootRef && this.rootRef.el;
|
||||
if (!root || !root.classList.contains("o_fp_cert_issue_wizard_form")) {
|
||||
return;
|
||||
}
|
||||
// First row that backs a line where is_ready is False (the data
|
||||
// toggle column renders as `false`). Fallback: the very first
|
||||
// data row.
|
||||
const dataRows = root.querySelectorAll(
|
||||
".o_field_one2many[name='line_ids'] .o_list_renderer .o_data_row"
|
||||
);
|
||||
if (!dataRows.length) {
|
||||
return;
|
||||
}
|
||||
let target = null;
|
||||
for (const row of dataRows) {
|
||||
// Look for an unchecked is_ready toggle inside the row. If
|
||||
// we find one, that row needs attention.
|
||||
const readyToggle = row.querySelector(
|
||||
"[name='is_ready'] input[type='checkbox']"
|
||||
);
|
||||
if (readyToggle && !readyToggle.checked) {
|
||||
target = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
target = dataRows[0];
|
||||
}
|
||||
// Find the fischer_file cell specifically - clicking THAT cell
|
||||
// (not just any cell) puts the row in edit mode AND focuses the
|
||||
// upload widget, so the native "Upload your file" link is the
|
||||
// very first thing the operator sees.
|
||||
const fischerCell = target.querySelector("[name='fischer_file']");
|
||||
if (fischerCell) {
|
||||
fischerCell.click();
|
||||
} else {
|
||||
// Fallback: click the row anywhere, then the upload column
|
||||
// shows up in the now-active row.
|
||||
target.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
registry.category("views").add("fp_cert_issue_wizard_form", {
|
||||
...formView,
|
||||
Controller: FpCertIssueWizardFormController,
|
||||
});
|
||||
@@ -1,676 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
/*
|
||||
* Record Inputs Dialog (Sub 12e v4)
|
||||
*
|
||||
* Replaces the form-view + list-as-cards CSS hack with a proper OWL
|
||||
* Dialog that owns its own DOM. No more fighting Odoo's editable list
|
||||
* renderer - semantic HTML, full visual control, dark-mode aware.
|
||||
*
|
||||
* Backend dispatch:
|
||||
* fp_job_step.action_open_input_wizard / action_finish_and_advance
|
||||
* return ir.actions.client { tag: 'fp_record_inputs_dialog', params }.
|
||||
* The action handler below opens the Dialog and returns nothing
|
||||
* (the action chain ends; the dialog manages itself).
|
||||
*
|
||||
* Dialog flow:
|
||||
* onWillStart → /fp/record_inputs/load → seed prompt rows
|
||||
* onSave → /fp/record_inputs/commit → advance step (optional)
|
||||
*/
|
||||
|
||||
import { Component, markup, onWillStart, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
|
||||
// Type categories - drives which input widget renders per row.
|
||||
const NUMERIC_TYPES = new Set([
|
||||
"number", "temperature", "thickness", "time_seconds", "ph",
|
||||
]);
|
||||
// Generic boolean only - pass_fail gets its own dedicated PASS/FAIL widget
|
||||
// because a bare Yes/No toggle gives the operator no context about which
|
||||
// state is the good outcome.
|
||||
const BOOLEAN_TYPES = new Set(["boolean"]);
|
||||
|
||||
// Human-friendly labels for the type pill in the card header. Without
|
||||
// this map the pill shows the raw key (e.g. "pass_fail") which looks like
|
||||
// a developer field name. The recipe author shouldn't see code identifiers.
|
||||
const TYPE_LABELS = {
|
||||
text: "Text",
|
||||
number: "Number",
|
||||
boolean: "Yes / No",
|
||||
selection: "Selection",
|
||||
date: "Date / Time",
|
||||
signature: "Signature",
|
||||
time_hms: "Time (HH:MM:SS)",
|
||||
time_seconds: "Time (sec)",
|
||||
temperature: "Temperature",
|
||||
thickness: "Thickness",
|
||||
pass_fail: "Pass / Fail",
|
||||
photo: "Photo",
|
||||
multi_point_thickness: "Thickness (5 readings)",
|
||||
bath_chemistry_panel: "Bath Chemistry",
|
||||
ph: "pH",
|
||||
};
|
||||
|
||||
|
||||
export class FpRecordInputsDialog extends Component {
|
||||
static template = "fusion_plating_jobs.FpRecordInputsDialog";
|
||||
static components = { Dialog };
|
||||
static props = ["stepId", "advanceAfter?", "close"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
saving: false,
|
||||
stepName: "",
|
||||
jobName: "",
|
||||
recipeRootId: false,
|
||||
rows: [],
|
||||
// Operator's persisted initials - pre-filled into signature
|
||||
// / "Reviewer Initials" prompts on load. When the operator
|
||||
// edits and saves a different value, the controller persists
|
||||
// it back to res.users.x_fc_initials so it sticks for every
|
||||
// future step / job.
|
||||
userInitials: "",
|
||||
// Recipe-author instructions: the description text and the
|
||||
// attached reference images (photos / screenshots / diagrams).
|
||||
// Surfaced at the top of the dialog before the prompt cards
|
||||
// so the operator sees them BEFORE entering values.
|
||||
instructionsHtml: "",
|
||||
instructionImages: [],
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this.loadPrompts();
|
||||
});
|
||||
}
|
||||
|
||||
async loadPrompts() {
|
||||
this.state.loading = true;
|
||||
const data = await rpc("/fp/record_inputs/load", {
|
||||
step_id: this.props.stepId,
|
||||
});
|
||||
if (!data.ok) {
|
||||
this.notification.add(
|
||||
data.error || _t("Could not load step prompts."),
|
||||
{ type: "danger" },
|
||||
);
|
||||
this.props.close();
|
||||
return;
|
||||
}
|
||||
this.state.stepName = data.step.name;
|
||||
this.state.jobName = data.job.name;
|
||||
this.state.recipeRootId = data.recipe_root_id || false;
|
||||
this.state.userInitials = data.user_initials || "";
|
||||
// `t-out` only renders unescaped HTML when the value is a
|
||||
// `markup()`-tagged string - otherwise it shows literal tags
|
||||
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
|
||||
this.state.instructionsHtml = markup(data.instructions_html || "");
|
||||
this.state.instructionImages = data.instruction_images || [];
|
||||
const nowDt = this._fpNowForDatetimeLocal();
|
||||
this.state.rows = data.prompts.map((p) => {
|
||||
const row = {
|
||||
...p,
|
||||
// value fields - initialized blank, populated as operator types
|
||||
value_text: "",
|
||||
value_number: 0,
|
||||
value_boolean: false,
|
||||
value_date: "",
|
||||
photo_value: false,
|
||||
photo_filename: "",
|
||||
point_1: 0, point_2: 0, point_3: 0,
|
||||
point_4: 0, point_5: 0,
|
||||
panel_ph: 0, panel_concentration: 0,
|
||||
panel_temperature: 0, panel_bath_id: "",
|
||||
// Pass/Fail explicit choice tracking - see onPass/onFail.
|
||||
_passfail_chosen: "",
|
||||
// Min / max range entry - see hasRangeEntry().
|
||||
value_min: 0,
|
||||
value_max: 0,
|
||||
};
|
||||
// ---- Sensible per-type defaults ------------------------------
|
||||
// Date / time → now. The operator can still adjust before save.
|
||||
if (this.isDate(row)) {
|
||||
row.value_date = nowDt;
|
||||
}
|
||||
// Pass / Fail defaults:
|
||||
// - Simple pass_fail (no target range) → default PASS so the
|
||||
// common "everything good" path is one less click.
|
||||
// - Range-based pass_fail (Bore A 0.005-0.007 etc.) → DO NOT
|
||||
// pre-select. The verdict must reflect the readings the
|
||||
// operator enters; pre-selecting PASS would silently
|
||||
// record PASS even when readings are out of spec.
|
||||
if (this.isPassFail(row) && !this.hasRangeEntry(row)) {
|
||||
row.value_boolean = true;
|
||||
row._passfail_chosen = "pass";
|
||||
}
|
||||
// Signature / "Reviewer Initials" / "Inspector Initials" /
|
||||
// similar prompts → pre-fill with the operator's persisted
|
||||
// initials so they don't retype the same letters on every
|
||||
// step. Heuristic: input_type=='signature' OR prompt name
|
||||
// contains 'initial' (case-insensitive).
|
||||
if (this._fpIsInitialsField(row)) {
|
||||
row.value_text = this.state.userInitials;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
// True when this row should be auto-populated from
|
||||
// ``state.userInitials``. Driven by input_type or a name keyword
|
||||
// so it works for "Reviewer Initials" (text), "Inspector Signature"
|
||||
// (signature), "Operator Initials" (text), etc.
|
||||
_fpIsInitialsField(row) {
|
||||
if (this.isSignature(row)) return true;
|
||||
if ((row.input_type || "") === "text") {
|
||||
const name = (row.name || "").toLowerCase();
|
||||
return name.includes("initial");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Current local datetime as "YYYY-MM-DDTHH:MM" (the format the
|
||||
// <input type="datetime-local"> widget accepts in t-model).
|
||||
_fpNowForDatetimeLocal() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return [
|
||||
d.getFullYear(),
|
||||
"-", pad(d.getMonth() + 1),
|
||||
"-", pad(d.getDate()),
|
||||
"T", pad(d.getHours()),
|
||||
":", pad(d.getMinutes()),
|
||||
].join("");
|
||||
}
|
||||
|
||||
// ---- Type predicates (used by the OWL template t-if) ----------------
|
||||
isNumeric(row) { return NUMERIC_TYPES.has(row.input_type); }
|
||||
isBoolean(row) { return BOOLEAN_TYPES.has(row.input_type); }
|
||||
isDate(row) { return row.input_type === "date"; }
|
||||
isPhoto(row) { return row.input_type === "photo"; }
|
||||
isMulti(row) { return row.input_type === "multi_point_thickness"; }
|
||||
isPanel(row) { return row.input_type === "bath_chemistry_panel"; }
|
||||
isSelection(row) { return row.input_type === "selection"; }
|
||||
isPassFail(row) { return row.input_type === "pass_fail"; }
|
||||
isSignature(row) { return row.input_type === "signature"; }
|
||||
isTimeHms(row) { return row.input_type === "time_hms"; }
|
||||
// Fallback to text for anything else
|
||||
isText(row) {
|
||||
return !this.isNumeric(row) && !this.isBoolean(row)
|
||||
&& !this.isDate(row) && !this.isPhoto(row)
|
||||
&& !this.isMulti(row) && !this.isPanel(row)
|
||||
&& !this.isSelection(row) && !this.isPassFail(row)
|
||||
&& !this.isSignature(row) && !this.isTimeHms(row);
|
||||
}
|
||||
|
||||
// Friendly label for the type pill - defaults to the raw key when no
|
||||
// mapping exists so a future input_type still renders something.
|
||||
inputTypeLabel(row) {
|
||||
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
|
||||
}
|
||||
|
||||
// Step granularity for <input type="number"> - drives the up/down
|
||||
// arrow increment AND the typed-decimal validity. Defaults of step=1
|
||||
// make tablet entry painful when the spec is 0.03 - 0.05 mil because
|
||||
// every arrow press jumps a full unit. Derive from the recipe-author's
|
||||
// target_min / target_max precision so operator arrow-taps move in the
|
||||
// same decimal magnitude the spec was written in. Falls back to
|
||||
// input-type defaults when no targets are set.
|
||||
stepFor(row) {
|
||||
const decimals = Math.max(
|
||||
this._fpCountDecimals(row.target_min),
|
||||
this._fpCountDecimals(row.target_max),
|
||||
);
|
||||
if (decimals > 0) {
|
||||
return Math.pow(10, -decimals).toFixed(decimals);
|
||||
}
|
||||
const t = row.input_type || "";
|
||||
if (t === "thickness" || t === "multi_point_thickness") return "0.0001";
|
||||
if (t === "ph") return "0.01";
|
||||
if (t === "temperature" || t === "time_seconds") return "1";
|
||||
return "any";
|
||||
}
|
||||
|
||||
// Stepper helpers - give the operator a tap-to-increment / -decrement
|
||||
// pair next to each numeric input so they don't have to open the
|
||||
// keyboard for small adjustments. Field is one of: value_number,
|
||||
// value_min, value_max. Increment uses stepFor() so taps move in the
|
||||
// same decimal magnitude the recipe spec was written in. Clamps at 0
|
||||
// (typical qty/time/temp on a plating shop floor doesn't go negative;
|
||||
// if a recipe needs negatives, the operator can still type the value
|
||||
// by tapping the input).
|
||||
_stepDelta(row) {
|
||||
const s = this.stepFor(row);
|
||||
if (s === "any") return 1;
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) || n <= 0 ? 1 : n;
|
||||
}
|
||||
|
||||
_stepRound(n, delta) {
|
||||
// Avoid floating-point fuzz (0.1+0.2=0.30000004). Round to the
|
||||
// delta's decimal precision.
|
||||
const decimals = (String(delta).split(".")[1] || "").length;
|
||||
if (!decimals) return Math.round(n);
|
||||
const factor = Math.pow(10, decimals);
|
||||
return Math.round(n * factor) / factor;
|
||||
}
|
||||
|
||||
onIncrement(row, field) {
|
||||
const cur = parseFloat(row[field]) || 0;
|
||||
const delta = this._stepDelta(row);
|
||||
row[field] = this._stepRound(cur + delta, delta);
|
||||
}
|
||||
|
||||
onDecrement(row, field) {
|
||||
const cur = parseFloat(row[field]) || 0;
|
||||
const delta = this._stepDelta(row);
|
||||
row[field] = Math.max(0, this._stepRound(cur - delta, delta));
|
||||
}
|
||||
|
||||
inputModeFor(row) {
|
||||
// Tablet keyboard hint - show numeric keypad instead of full
|
||||
// keyboard when the operator does tap the input. 'decimal' is
|
||||
// safer than 'numeric' because it includes the decimal point
|
||||
// (needed for pH, thickness, temperature).
|
||||
const t = row.input_type || "";
|
||||
if (t === "time_seconds" || t === "time_hms") return "numeric";
|
||||
return "decimal";
|
||||
}
|
||||
|
||||
_fpCountDecimals(n) {
|
||||
if (n === null || n === undefined || n === "" || n === 0) return 0;
|
||||
const s = String(n);
|
||||
const idx = s.indexOf(".");
|
||||
if (idx < 0) return 0;
|
||||
// Trim trailing zeros so "0.0500" doesn't look like 4-decimals
|
||||
// when the author actually wrote 2-decimal precision.
|
||||
return s.slice(idx + 1).replace(/0+$/, "").length;
|
||||
}
|
||||
|
||||
// Jump from the runtime dialog into the Simple Recipe Editor on the
|
||||
// EXACT recipe variant this job step is bound to. Closes the dialog
|
||||
// (operator returns by re-opening Record Inputs after editing). The
|
||||
// intent is to remove the "I edited the recipe but nothing changed"
|
||||
// confusion - they were editing a sibling variant.
|
||||
async openSimpleEditor() {
|
||||
if (!this.state.recipeRootId) {
|
||||
this.notification.add(
|
||||
_t("No recipe linked to this step yet."),
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.props.close();
|
||||
await this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_simple_recipe_editor",
|
||||
name: _t("Edit Recipe"),
|
||||
context: { recipe_id: this.state.recipeRootId },
|
||||
});
|
||||
}
|
||||
|
||||
// True when the recipe author defined BOTH target_min and target_max
|
||||
// on the prompt - the signal that the operator is expected to capture
|
||||
// a range (multiple readings → record their min and max observation).
|
||||
//
|
||||
// Fires for numeric AND pass_fail types: a Bore inspection is a
|
||||
// canonical example where the prompt is "PASS/FAIL" but the recipe
|
||||
// sets a target range (e.g. 0.005-0.007 in) - operator records the
|
||||
// observed min and max bore reading AND marks pass/fail.
|
||||
hasRangeEntry(row) {
|
||||
if (!row.target_min || !row.target_max) return false;
|
||||
if (row.target_min === row.target_max) return false;
|
||||
return this.isNumeric(row) || this.isPassFail(row);
|
||||
}
|
||||
|
||||
// Range hint for the dual-entry case - both bounds must be within
|
||||
// spec for a green "in range" verdict; otherwise call out which one
|
||||
// is the offender.
|
||||
dualRangeHint(row) {
|
||||
const lo = parseFloat(row.value_min);
|
||||
const hi = parseFloat(row.value_max);
|
||||
if (!lo && !hi) return null;
|
||||
if (hi && lo && hi < lo) {
|
||||
return { kind: "low", text: _t("max < min - check entry") };
|
||||
}
|
||||
if (lo && row.target_min && lo < row.target_min) {
|
||||
return { kind: "low", text: _t("min below target") };
|
||||
}
|
||||
if (hi && row.target_max && hi > row.target_max) {
|
||||
return { kind: "high", text: _t("max above target") };
|
||||
}
|
||||
if (lo && hi) {
|
||||
return { kind: "ok", text: _t("both in range") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pass/Fail handlers - set value_boolean explicitly per button.
|
||||
// Three states: undecided (false + nothing chosen yet), passed, failed.
|
||||
// We track the operator's CHOICE separately from the underlying boolean
|
||||
// so the buttons can show "FAIL" as the active state (which would
|
||||
// otherwise be indistinguishable from "not yet answered" in a plain
|
||||
// boolean field).
|
||||
onPass(row) {
|
||||
row.value_boolean = true;
|
||||
row._passfail_chosen = "pass";
|
||||
}
|
||||
onFail(row) {
|
||||
row.value_boolean = false;
|
||||
row._passfail_chosen = "fail";
|
||||
}
|
||||
isPassActive(row) { return row._passfail_chosen === "pass"; }
|
||||
isFailActive(row) { return row._passfail_chosen === "fail"; }
|
||||
|
||||
// Auto-suggested PASS/FAIL outcome when a pass_fail prompt has both
|
||||
// a target range and at least one reading entered. Returns 'pass',
|
||||
// 'fail', or '' (no suggestion). Drives the visual hint under the
|
||||
// dual-entry widget; the operator still has to click a button.
|
||||
suggestedPassFail(row) {
|
||||
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) return "";
|
||||
const lo = parseFloat(row.value_min);
|
||||
const hi = parseFloat(row.value_max);
|
||||
if (!lo && !hi) return "";
|
||||
const tmin = row.target_min;
|
||||
const tmax = row.target_max;
|
||||
const minOk = !lo || lo >= tmin;
|
||||
const maxOk = !hi || hi <= tmax;
|
||||
const sane = !lo || !hi || hi >= lo;
|
||||
return (minOk && maxOk && sane) ? "pass" : "fail";
|
||||
}
|
||||
|
||||
// ---- Selection options - recipe author may store as comma-sep ------
|
||||
selectionOptions(row) {
|
||||
const raw = row.selection_options || "";
|
||||
return raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---- Multi-point: live average of non-zero readings ----------------
|
||||
multiPointAvg(row) {
|
||||
const pts = [row.point_1, row.point_2, row.point_3,
|
||||
row.point_4, row.point_5].filter((v) => v);
|
||||
if (!pts.length) return 0;
|
||||
return (pts.reduce((a, b) => a + b, 0) / pts.length).toFixed(3);
|
||||
}
|
||||
|
||||
// ---- In-range hint for numeric - "in range" / "low" / "high" -------
|
||||
rangeHint(row) {
|
||||
if (!this.isNumeric(row)) return null;
|
||||
if (!row.target_min && !row.target_max) return null;
|
||||
const v = parseFloat(row.value_number);
|
||||
if (!v) return null;
|
||||
if (row.target_min && v < row.target_min) return { kind: "low", text: _t("below target") };
|
||||
if (row.target_max && v > row.target_max) return { kind: "high", text: _t("above target") };
|
||||
return { kind: "ok", text: _t("in range") };
|
||||
}
|
||||
|
||||
// Convert HTML5 datetime-local "YYYY-MM-DDTHH:MM[:SS]" to Odoo's
|
||||
// "YYYY-MM-DD HH:MM:SS". Returns false for empty / falsy input so
|
||||
// the field clears cleanly on the server side.
|
||||
_fpFormatDatetime(v) {
|
||||
if (!v) return false;
|
||||
let s = String(v).replace("T", " ");
|
||||
if (s.endsWith("Z")) {
|
||||
s = s.slice(0, -1);
|
||||
}
|
||||
// datetime-local without step gives "HH:MM" - pad to "HH:MM:SS".
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
|
||||
s += ":00";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- Photo upload - file -> base64 ----------------------------------
|
||||
async onPhotoChange(row, ev) {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target.result;
|
||||
row.photo_value = result.split(",")[1]; // strip data: URL prefix
|
||||
row.photo_filename = file.name;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
onPhotoClear(row) {
|
||||
row.photo_value = false;
|
||||
row.photo_filename = "";
|
||||
}
|
||||
|
||||
photoPreviewSrc(row) {
|
||||
if (!row.photo_value) return "";
|
||||
return "data:image/jpeg;base64," + row.photo_value;
|
||||
}
|
||||
|
||||
// ---- Add an ad-hoc measurement row ---------------------------------
|
||||
addAdHocRow() {
|
||||
this.state.rows.push({
|
||||
node_input_id: false,
|
||||
name: "",
|
||||
input_type: "text",
|
||||
required: false,
|
||||
target_min: 0,
|
||||
target_max: 0,
|
||||
target_unit: "",
|
||||
hint: "",
|
||||
selection_options: "",
|
||||
is_authored: false,
|
||||
value_text: "",
|
||||
value_number: 0,
|
||||
value_boolean: false,
|
||||
value_date: "",
|
||||
photo_value: false,
|
||||
photo_filename: "",
|
||||
point_1: 0, point_2: 0, point_3: 0,
|
||||
point_4: 0, point_5: 0,
|
||||
panel_ph: 0, panel_concentration: 0,
|
||||
panel_temperature: 0, panel_bath_id: "",
|
||||
_passfail_chosen: "",
|
||||
});
|
||||
}
|
||||
|
||||
removeRow(idx) {
|
||||
this.state.rows.splice(idx, 1);
|
||||
}
|
||||
|
||||
// Mirrors fp.job.step.input.wizard.line._has_value() Python helper.
|
||||
// Critical: the wizard SKIPS rows where _has_value() is False when
|
||||
// creating fp.job.step.move.input.value records, so the server-side
|
||||
// required-inputs gate considers them "not recorded". This client
|
||||
// check must match that semantic exactly or the server will reject
|
||||
// saves the operator thought were complete.
|
||||
_fpRowHasValue(row) {
|
||||
if (row.input_type === "photo") return !!row.photo_value;
|
||||
if (row.input_type === "multi_point_thickness") {
|
||||
return !!(row.point_1 || row.point_2 || row.point_3
|
||||
|| row.point_4 || row.point_5);
|
||||
}
|
||||
if (row.input_type === "bath_chemistry_panel") {
|
||||
return !!(row.panel_ph || row.panel_concentration
|
||||
|| row.panel_temperature || row.panel_bath_id);
|
||||
}
|
||||
if (row.input_type === "pass_fail") return !!row._passfail_chosen;
|
||||
// Boolean: value_boolean===true counts; untouched/false is
|
||||
// treated as no-value to match Python `any([..., self.value_
|
||||
// boolean, ...])`. Operators MUST affirmatively check the box.
|
||||
return !!(row.value_text || row.value_number
|
||||
|| row.value_boolean || row.value_date
|
||||
|| row.value_min || row.value_max);
|
||||
}
|
||||
|
||||
// The "current" initials value across all rows - a row counts as a
|
||||
// signature/initials field when ``_fpIsInitialsField`` is true.
|
||||
// Returns the most-recently-set value (last write wins) or empty.
|
||||
// The commit endpoint persists this back to res.users.x_fc_initials
|
||||
// when it differs from what was loaded.
|
||||
_fpCollectInitials() {
|
||||
let latest = "";
|
||||
for (const r of this.state.rows) {
|
||||
if (!this._fpIsInitialsField(r)) continue;
|
||||
const v = (r.value_text || "").trim();
|
||||
if (v) latest = v;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ---- Save ----------------------------------------------------------
|
||||
async onSave() {
|
||||
// Validate ad-hoc rows have a prompt name
|
||||
for (const row of this.state.rows) {
|
||||
if (!row.is_authored && !row.name.trim()) {
|
||||
this.notification.add(
|
||||
_t("Every ad-hoc measurement needs a Prompt label."),
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Validate range-based pass_fail rows: when readings are entered
|
||||
// (or the prompt is required), the operator must explicitly pick
|
||||
// PASS or FAIL. Otherwise readings would be recorded with no
|
||||
// verdict - silent ambiguity that breaks the audit trail.
|
||||
for (const row of this.state.rows) {
|
||||
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) continue;
|
||||
const hasReadings = row.value_min || row.value_max;
|
||||
const noChoice = !row._passfail_chosen;
|
||||
if ((hasReadings || row.required) && noChoice) {
|
||||
this.notification.add(
|
||||
_t("Mark PASS or FAIL on \"%s\" before saving.")
|
||||
.replace("%s", row.name || _t("the inspection prompt")),
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Required-prompt gate when finishing the step (advanceAfter=true).
|
||||
// Mirrors fp.job.step._fp_check_step_inputs_complete server-side
|
||||
// so the operator sees the missing fields instantly instead of
|
||||
// getting a server roundtrip error after the save commits. Partial
|
||||
// saves are still allowed when the dialog is opened from the
|
||||
// per-row Record button (advanceAfter=false).
|
||||
if (this.props.advanceAfter) {
|
||||
const missing = this.state.rows
|
||||
.filter((r) => r.required && !this._fpRowHasValue(r))
|
||||
.map((r) => r.name || _t("(unnamed)"));
|
||||
if (missing.length) {
|
||||
this.notification.add(
|
||||
_t("Cannot finish step - %n required prompt(s) missing: %list")
|
||||
.replace("%n", missing.length)
|
||||
.replace("%list", missing.map((n) => `"${n}"`).join(", ")),
|
||||
{ type: "danger", sticky: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.state.saving = true;
|
||||
const payload = this.state.rows.map((r) => {
|
||||
// When the prompt expects a range entry (min + max readings),
|
||||
// pack both into value_text for the audit trail and set
|
||||
// value_number to the larger reading so existing range checks
|
||||
// continue to work without a backend schema change. For
|
||||
// pass_fail prompts with range, the verdict (PASS or FAIL)
|
||||
// is appended too so the CoC shows the full inspection.
|
||||
let valueText = r.value_text || false;
|
||||
let valueNumber = r.value_number || 0;
|
||||
if (this.hasRangeEntry(r)
|
||||
&& (r.value_min || r.value_max)) {
|
||||
const lo = r.value_min || 0;
|
||||
const hi = r.value_max || 0;
|
||||
const unit = r.target_unit ? ` ${r.target_unit}` : "";
|
||||
let txt = `Min: ${lo}, Max: ${hi}${unit}`;
|
||||
if (this.isPassFail(r) && r._passfail_chosen) {
|
||||
txt += ` - ${r._passfail_chosen.toUpperCase()}`;
|
||||
}
|
||||
valueText = txt;
|
||||
valueNumber = hi || lo;
|
||||
}
|
||||
return {
|
||||
node_input_id: r.node_input_id || false,
|
||||
name: r.name,
|
||||
input_type: r.input_type,
|
||||
target_unit: r.target_unit,
|
||||
target_min: r.target_min,
|
||||
target_max: r.target_max,
|
||||
value_text: valueText,
|
||||
value_number: valueNumber,
|
||||
value_boolean: r.value_boolean,
|
||||
// datetime-local emits "YYYY-MM-DDTHH:MM" (or "...:SS")
|
||||
// Odoo's Datetime field needs "YYYY-MM-DD HH:MM:SS".
|
||||
// Normalise here so the wire payload is always valid.
|
||||
value_date: this._fpFormatDatetime(r.value_date),
|
||||
photo_value: r.photo_value || false,
|
||||
photo_filename: r.photo_filename || false,
|
||||
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
|
||||
point_4: r.point_4, point_5: r.point_5,
|
||||
panel_ph: r.panel_ph,
|
||||
panel_concentration: r.panel_concentration,
|
||||
panel_temperature: r.panel_temperature,
|
||||
panel_bath_id: r.panel_bath_id,
|
||||
};
|
||||
});
|
||||
const result = await rpc("/fp/record_inputs/commit", {
|
||||
step_id: this.props.stepId,
|
||||
values: payload,
|
||||
advance_after: !!this.props.advanceAfter,
|
||||
user_initials: this._fpCollectInitials(),
|
||||
});
|
||||
this.state.saving = false;
|
||||
if (!result.ok) {
|
||||
this.notification.add(
|
||||
result.error || _t("Save failed."),
|
||||
{ type: "danger" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.notification.add(
|
||||
_t("Inputs recorded."),
|
||||
{ type: "success" },
|
||||
);
|
||||
this.props.close();
|
||||
// Dispatch a meaningful next action when the backend returns one
|
||||
// (e.g. opening another form). Otherwise - and for the no-op
|
||||
// ir.actions.act_window_close case - soft-reload so the job form
|
||||
// behind the dialog re-fetches and the operator sees the step
|
||||
// state flip from In Progress -> Done without manually refreshing.
|
||||
const next = result.next_action;
|
||||
const isReal =
|
||||
next &&
|
||||
typeof next === "object" &&
|
||||
next.type !== "ir.actions.act_window_close";
|
||||
if (isReal) {
|
||||
await this.action.doAction(next);
|
||||
} else {
|
||||
await this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "soft_reload",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Register as a client action so backend Python can dispatch via:
|
||||
// { type: 'ir.actions.client', tag: 'fp_record_inputs_dialog', params: {...} }
|
||||
function fpRecordInputsDialogActionHandler(env, action) {
|
||||
env.services.dialog.add(FpRecordInputsDialog, {
|
||||
stepId: action.params.step_id,
|
||||
advanceAfter: action.params.advance_after || false,
|
||||
});
|
||||
// Action chain ends - dialog is self-managed.
|
||||
return { type: "ir.actions.act_window_close" };
|
||||
}
|
||||
|
||||
registry.category("actions").add(
|
||||
"fp_record_inputs_dialog",
|
||||
fpRecordInputsDialogActionHandler,
|
||||
);
|
||||
@@ -1,42 +0,0 @@
|
||||
// Pulsing vivid-green Finish & Next icon for the embedded step list.
|
||||
// Compiled into both web.assets_backend (bright) and web.assets_web_dark
|
||||
// (dark) - see fusion-plating/CLAUDE.md for the SCSS branch convention.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// Vivid finish-button colour. Branch at compile time so the dark
|
||||
// variant uses a lighter green that pops against the dark backdrop.
|
||||
$_fp_finish_color_hex: #16a34a; // Tailwind green-600
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp_finish_color_hex: #4ade80 !global; // green-400
|
||||
}
|
||||
|
||||
$fp-finish-color: var(--fp-finish-color, $_fp_finish_color_hex);
|
||||
|
||||
.o_fp_finish_btn {
|
||||
color: $fp-finish-color !important;
|
||||
|
||||
i.fa, i.oi {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
animation: fp_finish_pulse 1.8s ease-in-out infinite;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
// Pause the pulse on hover so the click target is steady.
|
||||
&:hover, &:focus {
|
||||
color: $fp-finish-color !important;
|
||||
opacity: 0.85;
|
||||
|
||||
i.fa, i.oi {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fp_finish_pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.18); }
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
// =============================================================================
|
||||
// Record Inputs Wizard - v3 card layout (light + dark mode)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
//
|
||||
// Replaces the long-row table layout (v2) with a stacked card layout -
|
||||
// one card per measurement prompt, the right input widget rendered per
|
||||
// type, target range + required indicator visible inline.
|
||||
//
|
||||
// Pattern (per fusion-plating/CLAUDE.md):
|
||||
// * SCSS branches at COMPILE TIME on $o-webclient-color-scheme
|
||||
// * File is registered in BOTH web.assets_backend AND web.assets_web_dark
|
||||
// * No reliance on runtime DOM classes (.o_dark_mode etc) - Odoo 19
|
||||
// does not flip dark mode via runtime; it serves a separate bundle.
|
||||
// * Tokens fall through to CSS custom properties so deployments can
|
||||
// override via :root { --fp-card-bg: ... } without touching SCSS.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---------- Surface tokens - branched at compile time ------------------------
|
||||
|
||||
$_fp-iw-card-hex : #ffffff;
|
||||
$_fp-iw-card-hover-hex: #f8f9fa;
|
||||
$_fp-iw-page-hex : #f3f4f6;
|
||||
$_fp-iw-border-hex : #d8dadd;
|
||||
$_fp-iw-border-focus-hex: #714B67; // Odoo brand purple
|
||||
$_fp-iw-ink-hex : #1f2937;
|
||||
$_fp-iw-ink-soft-hex : #4b5563;
|
||||
$_fp-iw-ink-mute-hex : #6b7280;
|
||||
$_fp-iw-ink-faint-hex : #9ca3af;
|
||||
$_fp-iw-required-hex : #dc3545; // red asterisk
|
||||
$_fp-iw-success-hex : #198754;
|
||||
$_fp-iw-pill-bg-hex : #f1f3f5;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-iw-card-hex : #22262d !global;
|
||||
$_fp-iw-card-hover-hex: #2a2f37 !global;
|
||||
$_fp-iw-page-hex : #1a1d21 !global;
|
||||
$_fp-iw-border-hex : #343942 !global;
|
||||
$_fp-iw-border-focus-hex: #a78bca !global; // lighter purple for dark
|
||||
$_fp-iw-ink-hex : #e5e7eb !global;
|
||||
$_fp-iw-ink-soft-hex : #c8ccd2 !global;
|
||||
$_fp-iw-ink-mute-hex : #8a909a !global;
|
||||
$_fp-iw-ink-faint-hex : #5a606b !global;
|
||||
$_fp-iw-required-hex : #ea868f !global;
|
||||
$_fp-iw-success-hex : #75b798 !global;
|
||||
$_fp-iw-pill-bg-hex : #1c2027 !global;
|
||||
}
|
||||
|
||||
// CSS-custom-property fallbacks so per-deployment overrides still work.
|
||||
$fp-iw-card : var(--fp-card-bg, #{$_fp-iw-card-hex});
|
||||
$fp-iw-card-hover : var(--fp-card-hover-bg, #{$_fp-iw-card-hover-hex});
|
||||
$fp-iw-page : var(--fp-page-bg, #{$_fp-iw-page-hex});
|
||||
$fp-iw-border : var(--fp-border-color, #{$_fp-iw-border-hex});
|
||||
$fp-iw-border-focus: var(--fp-border-focus, #{$_fp-iw-border-focus-hex});
|
||||
$fp-iw-ink : var(--fp-ink, #{$_fp-iw-ink-hex});
|
||||
$fp-iw-ink-soft : var(--fp-ink-soft, #{$_fp-iw-ink-soft-hex});
|
||||
$fp-iw-ink-mute : var(--fp-ink-mute, #{$_fp-iw-ink-mute-hex});
|
||||
$fp-iw-ink-faint : var(--fp-ink-faint, #{$_fp-iw-ink-faint-hex});
|
||||
$fp-iw-required : var(--fp-required, #{$_fp-iw-required-hex});
|
||||
$fp-iw-success : var(--fp-success, #{$_fp-iw-success-hex});
|
||||
$fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Wizard layout - header + section title + card grid + empty state
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_input_wizard_v3 {
|
||||
background-color: $fp-iw-page;
|
||||
|
||||
.o_fp_input_header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $fp-iw-border;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: $fp-iw-ink;
|
||||
}
|
||||
.o_fp_input_subhead {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $fp-iw-ink-mute;
|
||||
|
||||
// The job_id field renders as an inline anchor; keep the
|
||||
// colour calm so the section title stays the focal point.
|
||||
a, .o_field_widget {
|
||||
color: $fp-iw-ink-soft;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_input_section_title {
|
||||
margin: 8px 0 12px 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $fp-iw-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_input_empty_state {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: $fp-iw-ink-mute;
|
||||
background-color: $fp-iw-card;
|
||||
border: 1px dashed $fp-iw-border;
|
||||
border-radius: 12px;
|
||||
|
||||
strong {
|
||||
color: $fp-iw-ink-soft;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// List → cards transformation
|
||||
//
|
||||
// We keep Odoo's <list editable="bottom"> for inline editing semantics
|
||||
// (operators tab through cells, Enter saves the row) and re-render it
|
||||
// as a stack of cards via CSS only. No JS, no OWL component - the
|
||||
// existing wizard model is unchanged.
|
||||
//
|
||||
// Strategy: turn each <table>/<tr>/<td> into block-level / grid
|
||||
// containers. Hide column headers entirely. Use CSS Grid on each row
|
||||
// to position prompt + meta + value into a card layout.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_input_card_list {
|
||||
// Override the default list chrome - no border, no horizontal scroll
|
||||
.o_list_renderer {
|
||||
background: transparent;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.o_list_table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-collapse: separate;
|
||||
background: transparent;
|
||||
|
||||
// No column headers - each card carries its own labels
|
||||
> thead { display: none; }
|
||||
|
||||
> tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Each row becomes a card. Five-column grid so the meta cells
|
||||
// (input_type pill, target_unit pill) each land in their own
|
||||
// column instead of stacking on top of each other.
|
||||
tr.o_data_row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
grid-template-areas:
|
||||
"prompt type unit trash"
|
||||
"value value value value"
|
||||
"extras extras extras extras";
|
||||
gap: 10px 12px;
|
||||
align-items: start;
|
||||
padding: 16px 20px;
|
||||
background-color: $fp-iw-card;
|
||||
border: 1px solid $fp-iw-border;
|
||||
border-radius: 12px;
|
||||
transition: border-color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $fp-iw-card-hover;
|
||||
}
|
||||
&:focus-within {
|
||||
border-color: $fp-iw-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, #{$fp-iw-border-focus} 18%, transparent);
|
||||
}
|
||||
|
||||
// Per-cell rest - strip table styling; we'll re-position via
|
||||
// grid-area on the cells we actually want visible.
|
||||
> td {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
vertical-align: top;
|
||||
|
||||
// Inputs inherit row width
|
||||
.o_field_widget {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive: any cell whose widget is logically invisible
|
||||
// (Odoo's invisible="..." attr) drops out of the grid so it
|
||||
// doesn't punch an empty slot in our layout.
|
||||
> td:has(.o_invisible_modifier),
|
||||
> td.o_invisible_modifier,
|
||||
> td:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// ---------- Card header - prompt name ----------
|
||||
// Char field renders as bare <span> (read) or <input> (edit)
|
||||
// directly in the td. Style typography on the td and make
|
||||
// the inner element inherit + transparent.
|
||||
td.o_fp_iw_prompt {
|
||||
grid-area: prompt;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $fp-iw-ink;
|
||||
line-height: 1.4;
|
||||
|
||||
> span,
|
||||
> input,
|
||||
> input.o_input {
|
||||
display: block;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
font: inherit !important;
|
||||
line-height: inherit !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
cursor: text;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
|
||||
&[readonly], &:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Meta pills - type + unit each in its OWN column ----
|
||||
// Both fields carry the .o_fp_iw_meta class for shared
|
||||
// pill styling, plus a distinct class (_type / _unit) so
|
||||
// CSS Grid can put each in its own area.
|
||||
td.o_fp_iw_meta_type { grid-area: type; }
|
||||
td.o_fp_iw_meta_unit { grid-area: unit; }
|
||||
|
||||
// Meta pills - input_type (Selection) and target_unit
|
||||
// (Selection) render as bare <span> (read mode) or <select>
|
||||
// (edit mode) directly inside the td. Style the td as a
|
||||
// pill, make the inner element transparent.
|
||||
td.o_fp_iw_meta {
|
||||
align-self: center;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
width: auto !important;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px !important;
|
||||
background-color: $fp-iw-pill-bg !important;
|
||||
color: $fp-iw-ink-soft !important;
|
||||
border: 1px solid $fp-iw-border !important;
|
||||
border-radius: 999px !important;
|
||||
line-height: 1.2 !important;
|
||||
|
||||
> span,
|
||||
> select,
|
||||
> input,
|
||||
> input.o_input {
|
||||
display: inline-block;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
font: inherit !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
line-height: inherit !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Value - the live widget for this row's type --------
|
||||
//
|
||||
// VERIFIED FROM ODOO 19 SOURCE
|
||||
// (web/static/src/views/fields/float/float_field.xml +
|
||||
// web/static/src/views/fields/char/char_field.xml +
|
||||
// web/static/src/views/list/list_renderer.xml)
|
||||
//
|
||||
// Float/Char/Date fields render as a BARE <span> (read mode)
|
||||
// or BARE <input class="o_input"> (edit mode) directly inside
|
||||
// the <td>. There is NO .o_field_widget wrapper. So the
|
||||
// visible "input box" chrome must go on the <td> itself.
|
||||
// The inner span/input is then made transparent.
|
||||
//
|
||||
// Cells with widget="boolean_toggle"/"image" don't get our
|
||||
// o_fp_iw_value class at all (Odoo's canUseFormatter strips
|
||||
// custom classes when column.widget is set), so they render
|
||||
// natively - handled in their own rules below.
|
||||
// ---------------------------------------------------------------
|
||||
td.o_fp_iw_value {
|
||||
grid-area: value;
|
||||
// Override the global "td { display: block; }" rule above -
|
||||
// we need flex layout so the inner span/input centers.
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 420px;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px !important;
|
||||
background-color: $fp-iw-page !important;
|
||||
color: $fp-iw-ink !important;
|
||||
border: 1px solid $fp-iw-ink-faint !important;
|
||||
border-radius: 8px !important;
|
||||
cursor: text;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
transition: border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $fp-iw-ink-mute !important;
|
||||
}
|
||||
|
||||
// Focus ring travels up from the inner input to the
|
||||
// td via :focus-within (works even though the input
|
||||
// is the focused element, not the td).
|
||||
&:focus-within {
|
||||
border-color: $fp-iw-border-focus !important;
|
||||
background-color: $fp-iw-card !important;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb,
|
||||
#{$fp-iw-border-focus} 25%, transparent) !important;
|
||||
}
|
||||
|
||||
// Inner span (read mode) - fills the cell, left-aligned,
|
||||
// inherits typography from the td.
|
||||
> span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
// Inner input (edit mode) - same treatment as the span,
|
||||
// fully transparent so the td chrome shows through.
|
||||
> input,
|
||||
> input.o_input,
|
||||
> input[type="text"],
|
||||
> input[type="number"],
|
||||
> input[type="datetime-local"] {
|
||||
flex: 1 1 auto;
|
||||
width: 100% !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
text-align: left !important;
|
||||
font: inherit !important;
|
||||
line-height: inherit !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
|
||||
&::placeholder {
|
||||
color: $fp-iw-ink-faint;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Boolean toggle cells - render bare ---------------
|
||||
// value_boolean has widget="boolean_toggle" so canUseFormatter
|
||||
// returns false → our o_fp_iw_value class is NOT added.
|
||||
// We target the cell via the type flag column's td position
|
||||
// (4th visible td when is_boolean_type) - easier: target the
|
||||
// toggle directly anywhere it appears.
|
||||
.o_boolean_toggle,
|
||||
.form-switch {
|
||||
transform: scale(1.5);
|
||||
transform-origin: left center;
|
||||
margin: 12px 0 12px 16px;
|
||||
}
|
||||
|
||||
// ---------- Image / photo cells - constrain ------------------
|
||||
// Same canUseFormatter issue - widget="image" strips our class.
|
||||
// Target the o_field_image natively wherever it lands.
|
||||
.o_field_image {
|
||||
max-width: 240px;
|
||||
|
||||
img,
|
||||
.o_image,
|
||||
.o_form_uri,
|
||||
.o_form_image_controls {
|
||||
max-width: 240px;
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $fp-iw-border;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Extras - composite types (multi-point, panel) ----
|
||||
// Same approach as value cells: chrome on the td itself,
|
||||
// because Float fields render as bare span/input.
|
||||
td.o_fp_iw_extra {
|
||||
grid-area: extras;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
width: 80px !important;
|
||||
min-height: 38px;
|
||||
padding: 6px 10px !important;
|
||||
margin-right: 12px;
|
||||
background-color: $fp-iw-page !important;
|
||||
color: $fp-iw-ink !important;
|
||||
border: 1px solid $fp-iw-ink-faint !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: text;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $fp-iw-border-focus !important;
|
||||
background-color: $fp-iw-card !important;
|
||||
}
|
||||
|
||||
// Per-cell label (R1/R2/.../pH/Conc/Temp/Bath) is the
|
||||
// field's `string=` attribute - Odoo renders it inside
|
||||
// the cell when in form mode but skips it in list mode.
|
||||
// We surface it via the data-label attr if present, or
|
||||
// fall back to no-label (composite cells stack inline
|
||||
// and the visual order makes them obvious).
|
||||
|
||||
> span,
|
||||
> input,
|
||||
> input.o_input {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
text-align: left !important;
|
||||
font: inherit !important;
|
||||
line-height: inherit !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Trash button - hidden by default to declutter the card.
|
||||
// Operators rarely need to delete authored prompts; ad-hoc
|
||||
// rows can still be removed via the row's context menu.
|
||||
// Show on row hover for power users.
|
||||
td.o_list_record_remove {
|
||||
grid-area: trash;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
|
||||
button {
|
||||
color: $fp-iw-ink-faint;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
color: $fp-iw-required;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover td.o_list_record_remove,
|
||||
&:focus-within td.o_list_record_remove {
|
||||
opacity: 0.6;
|
||||
}
|
||||
td.o_list_record_remove:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// "Add a line" footer - make it a tasteful CTA card
|
||||
tfoot, .o_field_x2many_list_row_add {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
|
||||
a, td {
|
||||
display: inline-block;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: $fp-iw-ink-soft;
|
||||
background-color: $fp-iw-card;
|
||||
border: 1px dashed $fp-iw-border;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $fp-iw-border-focus;
|
||||
border-color: $fp-iw-border-focus;
|
||||
background-color: $fp-iw-card-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Tablet polish - operators on shop-floor tablets need bigger touch targets
|
||||
// =============================================================================
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.o_fp_input_card_list .o_list_table tr.o_data_row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"prompt trash"
|
||||
"type unit"
|
||||
"value value"
|
||||
"extras extras";
|
||||
|
||||
td.o_fp_iw_meta_type,
|
||||
td.o_fp_iw_meta_unit {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
td.o_fp_iw_value {
|
||||
.o_field_widget { max-width: 100%; }
|
||||
input { min-height: 56px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,849 +0,0 @@
|
||||
// =============================================================================
|
||||
// Record Inputs Dialog - Sub 12e v4 (proper OWL component)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
//
|
||||
// Pure semantic HTML inside a Dialog. No list view to fight, no
|
||||
// table-cell unwinding, no class-stripping bugs. Just cards.
|
||||
//
|
||||
// Dark mode: branched at compile time on $o-webclient-color-scheme,
|
||||
// per fusion-plating/CLAUDE.md. Registered in BOTH backend + dark
|
||||
// asset bundles.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---------- Surface tokens ---------------------------------------------------
|
||||
|
||||
$_fp-rid-card-hex : #ffffff;
|
||||
$_fp-rid-card-hover-hex: #f8f9fa;
|
||||
$_fp-rid-page-hex : #f3f4f6;
|
||||
$_fp-rid-input-hex : #ffffff;
|
||||
$_fp-rid-pill-hex : #f1f3f5;
|
||||
$_fp-rid-border-hex : #d8dadd;
|
||||
$_fp-rid-border-strong-hex: #b6babf;
|
||||
$_fp-rid-border-focus-hex : #714B67;
|
||||
$_fp-rid-ink-hex : #1f2937;
|
||||
$_fp-rid-ink-soft-hex : #4b5563;
|
||||
$_fp-rid-ink-mute-hex : #6b7280;
|
||||
$_fp-rid-ink-faint-hex : #9ca3af;
|
||||
$_fp-rid-required-hex : #dc3545;
|
||||
$_fp-rid-ok-hex : #198754;
|
||||
$_fp-rid-warn-hex : #b18307;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-rid-card-hex : #2a2f37 !global;
|
||||
$_fp-rid-card-hover-hex: #323843 !global;
|
||||
$_fp-rid-page-hex : #1a1d21 !global;
|
||||
$_fp-rid-input-hex : #1a1d21 !global;
|
||||
$_fp-rid-pill-hex : #353a44 !global;
|
||||
$_fp-rid-border-hex : #3f4651 !global;
|
||||
$_fp-rid-border-strong-hex: #5a606b !global;
|
||||
$_fp-rid-border-focus-hex : #a78bca !global;
|
||||
$_fp-rid-ink-hex : #e5e7eb !global;
|
||||
$_fp-rid-ink-soft-hex : #c8ccd2 !global;
|
||||
$_fp-rid-ink-mute-hex : #8a909a !global;
|
||||
$_fp-rid-ink-faint-hex : #6a707b !global;
|
||||
$_fp-rid-required-hex : #ea868f !global;
|
||||
$_fp-rid-ok-hex : #75b798 !global;
|
||||
$_fp-rid-warn-hex : #ffd866 !global;
|
||||
}
|
||||
|
||||
$rid-card : var(--fp-rid-card-bg, #{$_fp-rid-card-hex});
|
||||
$rid-card-hover : var(--fp-rid-card-hover-bg, #{$_fp-rid-card-hover-hex});
|
||||
$rid-page : var(--fp-rid-page-bg, #{$_fp-rid-page-hex});
|
||||
$rid-input : var(--fp-rid-input-bg, #{$_fp-rid-input-hex});
|
||||
$rid-pill : var(--fp-rid-pill-bg, #{$_fp-rid-pill-hex});
|
||||
$rid-border : var(--fp-rid-border, #{$_fp-rid-border-hex});
|
||||
$rid-border-strong: var(--fp-rid-border-strong, #{$_fp-rid-border-strong-hex});
|
||||
$rid-border-focus: var(--fp-rid-border-focus, #{$_fp-rid-border-focus-hex});
|
||||
$rid-ink : var(--fp-rid-ink, #{$_fp-rid-ink-hex});
|
||||
$rid-ink-soft : var(--fp-rid-ink-soft, #{$_fp-rid-ink-soft-hex});
|
||||
$rid-ink-mute : var(--fp-rid-ink-mute, #{$_fp-rid-ink-mute-hex});
|
||||
$rid-ink-faint : var(--fp-rid-ink-faint, #{$_fp-rid-ink-faint-hex});
|
||||
$rid-required : var(--fp-rid-required, #{$_fp-rid-required-hex});
|
||||
$rid-ok : var(--fp-rid-ok, #{$_fp-rid-ok-hex});
|
||||
$rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Dialog frame - generous body, scrollable card stack
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_dialog_content {
|
||||
.modal-body {
|
||||
padding: 16px 20px;
|
||||
background-color: $rid-page;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.o_fp_ri_step_name {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $rid-ink;
|
||||
}
|
||||
.o_fp_ri_job_label {
|
||||
font-size: 0.875rem;
|
||||
color: $rid-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_ri_loading,
|
||||
.o_fp_ri_empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: $rid-ink-mute;
|
||||
background-color: $rid-card;
|
||||
border: 1px dashed $rid-border;
|
||||
border-radius: 12px;
|
||||
|
||||
p { margin-bottom: 12px; }
|
||||
i.fa-spinner { color: $rid-border-focus; font-size: 1.5rem; }
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Card stack
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.o_fp_ri_card {
|
||||
padding: 16px 20px;
|
||||
background-color: $rid-card;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 12px;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $rid-border-strong;
|
||||
}
|
||||
&:focus-within {
|
||||
border-color: $rid-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, #{$rid-border-focus} 18%, transparent);
|
||||
}
|
||||
|
||||
&.o_fp_ri_card_required {
|
||||
border-left: 3px solid $rid-border-focus;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Card header - prompt + meta + remove ----------------------------
|
||||
|
||||
.o_fp_ri_card_head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.o_fp_ri_prompt {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_fp_ri_prompt_label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $rid-ink;
|
||||
}
|
||||
.o_fp_ri_required_mark {
|
||||
color: $rid-required;
|
||||
font-weight: 700;
|
||||
}
|
||||
.o_fp_ri_prompt_input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
outline: none;
|
||||
}
|
||||
&::placeholder { color: $rid-ink-faint; }
|
||||
}
|
||||
|
||||
.o_fp_ri_meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.o_fp_ri_pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
background-color: $rid-pill;
|
||||
color: $rid-ink-soft;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.o_fp_ri_pill_unit {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
color: $rid-ink-mute;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.o_fp_ri_remove {
|
||||
flex-shrink: 0;
|
||||
color: $rid-ink-faint;
|
||||
padding: 4px 8px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 120ms ease, color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: $rid-required;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Target / hint helpers ------------------------------------------
|
||||
|
||||
// Target pill - surfaces the recipe-author's target_min / target_max
|
||||
// (the "spec") so the operator knows what they're aiming for BEFORE
|
||||
// they enter readings. Reads as a small inline badge with bullseye
|
||||
// icon, separated visually from the body / hint copy.
|
||||
.o_fp_ri_target {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 10px 0;
|
||||
padding: 4px 10px;
|
||||
background-color: rgba(46, 125, 107, .10);
|
||||
border: 1px solid rgba(46, 125, 107, .25);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
color: $rid-ok;
|
||||
|
||||
.fa-bullseye { color: $rid-ok; }
|
||||
|
||||
.o_fp_ri_target_label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.o_fp_ri_target_value {
|
||||
color: $rid-ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.o_fp_ri_target_unit {
|
||||
margin-left: 2px;
|
||||
color: $rid-ink-mute;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.o_fp_ri_hint {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.8125rem;
|
||||
color: $rid-ink-faint;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Instructions block - recipe author's narrative text + image gallery,
|
||||
// rendered above the prompt cards so the operator reads context BEFORE
|
||||
// entering values. Hidden by the t-if when neither piece is authored.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_instructions {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
background-color: $rid-card;
|
||||
border: 1px solid $rid-border;
|
||||
border-left: 4px solid $rid-border-focus;
|
||||
border-radius: 6px;
|
||||
color: $rid-ink;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .03);
|
||||
|
||||
.o_fp_ri_instructions_text {
|
||||
font-size: .95rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
|
||||
// Reset the rich-text fragments coming out of the HTML field
|
||||
// so they render predictably inside the dialog frame.
|
||||
:first-child { margin-top: 0; }
|
||||
:last-child { margin-bottom: 0; }
|
||||
img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
}
|
||||
|
||||
.o_fp_ri_instructions_gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.o_fp_ri_instructions_thumb {
|
||||
display: inline-block;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: $rid-page;
|
||||
cursor: zoom-in;
|
||||
transition: transform .12s ease, border-color .12s ease,
|
||||
box-shadow .12s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
border-color: $rid-border-focus;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Card body - inputs per type
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_card_body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Common input chrome --------------------------------------------
|
||||
|
||||
.o_fp_ri_input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 10px 14px;
|
||||
min-height: 48px;
|
||||
background-color: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 8px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&::placeholder { color: $rid-ink-faint; font-weight: 400; }
|
||||
|
||||
&:hover {
|
||||
border-color: $rid-ink-mute;
|
||||
}
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, #{$rid-border-focus} 25%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_input_select {
|
||||
appearance: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_fp_ri_input_numeric {
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Numeric - input + range hint -----------------------------------
|
||||
|
||||
.o_fp_ri_numeric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.o_fp_ri_range_hint {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
&.o_fp_ri_range_ok {
|
||||
background-color: color-mix(in srgb, #{$rid-ok} 15%, transparent);
|
||||
color: $rid-ok;
|
||||
}
|
||||
&.o_fp_ri_range_low,
|
||||
&.o_fp_ri_range_high {
|
||||
background-color: color-mix(in srgb, #{$rid-warn} 18%, transparent);
|
||||
color: $rid-warn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Boolean toggle (custom - bigger + clearer than Bootstrap) ------
|
||||
|
||||
.o_fp_ri_toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
> input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
}
|
||||
.o_fp_ri_toggle_track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
background-color: $rid-pill;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 999px;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
.o_fp_ri_toggle_thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: $rid-ink-mute;
|
||||
border-radius: 50%;
|
||||
transition: transform 150ms ease, background-color 150ms ease;
|
||||
}
|
||||
.o_fp_ri_toggle > input:checked ~ .o_fp_ri_toggle_track {
|
||||
background-color: $rid-border-focus;
|
||||
border-color: $rid-border-focus;
|
||||
|
||||
.o_fp_ri_toggle_thumb {
|
||||
transform: translateX(24px);
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
.o_fp_ri_toggle_label {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $rid-ink-soft;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Photo upload - modest size, semantic ---------------------------
|
||||
|
||||
.o_fp_ri_photo {
|
||||
display: inline-block;
|
||||
}
|
||||
.o_fp_ri_photo_placeholder .btn {
|
||||
background-color: $rid-pill;
|
||||
color: $rid-ink-soft;
|
||||
border: 1px dashed $rid-border-strong;
|
||||
padding: 12px 18px;
|
||||
|
||||
&:hover {
|
||||
border-color: $rid-border-focus;
|
||||
color: $rid-border-focus;
|
||||
}
|
||||
}
|
||||
.o_fp_ri_photo_preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $rid-border;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Multi-point thickness ------------------------------------------
|
||||
|
||||
.o_fp_ri_multi_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(70px, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.75rem;
|
||||
color: $rid-ink-mute;
|
||||
font-weight: 600;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_fp_ri_multi_avg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 1.125rem;
|
||||
color: $rid-ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Bath chemistry panel - same shape as multi ---------------------
|
||||
|
||||
.o_fp_ri_panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.75rem;
|
||||
color: $rid-ink-mute;
|
||||
font-weight: 600;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Add-row CTA -----------------------------------------------------
|
||||
|
||||
.o_fp_ri_add_btn {
|
||||
align-self: flex-start;
|
||||
padding: 10px 18px;
|
||||
color: $rid-ink-soft;
|
||||
background-color: $rid-card;
|
||||
border: 1px dashed $rid-border-strong;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $rid-border-focus;
|
||||
border-color: $rid-border-focus;
|
||||
background-color: $rid-card-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Tablet polish - bigger inputs on narrow screens
|
||||
// =============================================================================
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_ri_card_head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.o_fp_ri_meta {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
.o_fp_ri_input {
|
||||
max-width: 100%;
|
||||
min-height: 56px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.o_fp_ri_multi_grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.o_fp_ri_panel {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Pass / Fail - distinct two-button widget
|
||||
//
|
||||
// A bare boolean toggle hid the question's intent ("PASS or FAIL?" → "Yes
|
||||
// or No?"). Two clearly-coloured buttons mirror the language the operator
|
||||
// already speaks: green PASS, red FAIL. Active button fills with the
|
||||
// outcome colour; inactive stays outlined.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_passfail {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.o_fp_ri_pf_btn {
|
||||
flex: 1;
|
||||
min-height: 52px;
|
||||
padding: 10px 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color .12s ease, color .12s ease,
|
||||
border-color .12s ease, transform .04s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_pf_pass {
|
||||
border: 1.5px solid $rid-ok;
|
||||
color: $rid-ok;
|
||||
|
||||
&:hover { background-color: rgba(25, 135, 84, .08); }
|
||||
&.o_fp_ri_pf_active {
|
||||
background-color: $rid-ok;
|
||||
color: #ffffff;
|
||||
border-color: $rid-ok;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_pf_fail {
|
||||
border: 1.5px solid $rid-required;
|
||||
color: $rid-required;
|
||||
|
||||
&:hover { background-color: rgba(220, 53, 69, .08); }
|
||||
&.o_fp_ri_pf_active {
|
||||
background-color: $rid-required;
|
||||
color: #ffffff;
|
||||
border-color: $rid-required;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Signature - clearly-affordance'd input so operators know it's an
|
||||
// initial / signature, not free text.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_signature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: $rid-input;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 6px;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $rid-border-focus;
|
||||
box-shadow: 0 0 0 .15rem rgba(113, 75, 103, .15);
|
||||
}
|
||||
|
||||
.o_fp_ri_signature_icon {
|
||||
font-size: 1.1rem;
|
||||
color: $rid-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_ri_input_signature {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 0;
|
||||
font-family: "Courier New", "Lucida Console", monospace;
|
||||
font-size: 1rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: $rid-ink;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Selection - empty-state hint when recipe author didn't authoring options
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_select_empty {
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed $rid-border-strong;
|
||||
border-radius: 6px;
|
||||
background-color: $rid-page;
|
||||
color: $rid-ink-mute;
|
||||
font-size: .9rem;
|
||||
|
||||
.fa-info-circle {
|
||||
color: $rid-warn;
|
||||
}
|
||||
|
||||
.o_fp_ri_input_text {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Dual-entry numeric - Min Reading + Max Reading side-by-side
|
||||
//
|
||||
// Fires when the recipe author authored both target_min AND target_max on
|
||||
// a numeric prompt (signal: this measurement is a range, not a point).
|
||||
// Operator records the lowest and highest reading from their inspection
|
||||
// pass. The hint below verifies BOTH bounds are within spec.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_dual {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
|
||||
.o_fp_ri_dual_field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.o_fp_ri_dual_label {
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
color: $rid-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_ri_dual_hint {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// PASS/FAIL suggestion banner - fires when a pass_fail prompt has both a
|
||||
// target range and the operator has entered Min/Max readings. Shows the
|
||||
// suggested verdict so the operator knows what the system thinks before
|
||||
// they tap PASS or FAIL.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_pf_suggest {
|
||||
margin: 8px 0 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: .9rem;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.o_fp_ri_pf_suggest_pass {
|
||||
background-color: rgba(25, 135, 84, .10);
|
||||
border-color: rgba(25, 135, 84, .35);
|
||||
color: $rid-ok;
|
||||
}
|
||||
|
||||
&.o_fp_ri_pf_suggest_fail {
|
||||
background-color: rgba(220, 53, 69, .10);
|
||||
border-color: rgba(220, 53, 69, .35);
|
||||
color: $rid-required;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric stepper (tap-to-increment / tap-to-decrement around the input).
|
||||
// Operator can still tap the input to open the keypad (inputmode="decimal"
|
||||
// gives them the number keypad on iPad).
|
||||
.o_fp_ri_stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.o_fp_ri_stepper_btn {
|
||||
flex: 0 0 auto;
|
||||
width: 3.2rem;
|
||||
border: 1px solid #cdd0d4;
|
||||
background: #f5f6f8;
|
||||
color: #1d1d1f;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: background 80ms;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover, &:active { background: #e5e7eb; }
|
||||
|
||||
&.o_fp_ri_stepper_minus {
|
||||
border-top-left-radius: 6px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-right: 0;
|
||||
}
|
||||
&.o_fp_ri_stepper_plus {
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_stepper_input {
|
||||
flex: 1 1 auto;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
min-width: 4rem;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Step Details Quick-Look modal - dark-mode aware tokens.
|
||||
// Pattern documented in fusion-plating/CLAUDE.md: branch hex values
|
||||
// at SCSS compile-time via $o-webclient-color-scheme. Don't rely on
|
||||
// var(--bs-body-bg) - Odoo 19 doesn't flip it consistently across
|
||||
// addons.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_fp_ql_panel_hex: #f8f9fa;
|
||||
$_fp_ql_border_hex: #d8dadd;
|
||||
$_fp_ql_text_hex: #212529;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp_ql_panel_hex: #22262d !global;
|
||||
$_fp_ql_border_hex: #3a3f47 !global;
|
||||
$_fp_ql_text_hex: #e8eaed !global;
|
||||
}
|
||||
|
||||
$fp-ql-panel: var(--fp-card-bg, #{$_fp_ql_panel_hex});
|
||||
$fp-ql-border: var(--fp-border-color, #{$_fp_ql_border_hex});
|
||||
$fp-ql-text: var(--fp-text, #{$_fp_ql_text_hex});
|
||||
|
||||
// Compress the info-button column on the embedded step list. Odoo
|
||||
// gives button columns a generous min-width by default, leaving an
|
||||
// awkward gap between the icon and the Name column. Belt + braces:
|
||||
// the <button width="30"/> attribute does most of the work; these
|
||||
// rules tighten the inner padding so the icon hugs the cell edges.
|
||||
.o_list_view td:has(> button.o_fp_step_info_btn),
|
||||
.o_list_view td:has(> .o_list_button_cell button.o_fp_step_info_btn) {
|
||||
width: 32px !important;
|
||||
max-width: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding-left: 2px !important;
|
||||
padding-right: 2px !important;
|
||||
}
|
||||
button.o_fp_step_info_btn {
|
||||
padding: 2px 6px !important;
|
||||
margin: 0 !important;
|
||||
min-width: 0 !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
button.o_fp_step_info_btn .fa,
|
||||
button.o_fp_step_info_btn i {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
// Container around the rich-text instructions inside the quick-look
|
||||
// modal. Bordered + scrollable + readable in both light and dark modes.
|
||||
.o_fp_quick_look_instructions {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
background: $fp-ql-panel;
|
||||
color: $fp-ql-text;
|
||||
border: 1px solid $fp-ql-border;
|
||||
border-radius: 4px;
|
||||
|
||||
// Make sure rich-text inner elements inherit the readable colour.
|
||||
p, li, span, strong, em, h1, h2, h3, h4, h5, h6 {
|
||||
color: inherit;
|
||||
}
|
||||
ul, ol { margin-bottom: 0; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_jobs.FpRecordInputsDialog">
|
||||
<Dialog size="'lg'" contentClass="'o_fp_ri_dialog_content'">
|
||||
<t t-set-slot="header">
|
||||
<div class="o_fp_ri_header">
|
||||
<div class="o_fp_ri_header_titles">
|
||||
<h4 class="o_fp_ri_step_name" t-esc="state.stepName"/>
|
||||
<span class="o_fp_ri_job_label">
|
||||
Job <t t-esc="state.jobName"/>
|
||||
</span>
|
||||
</div>
|
||||
<button t-if="state.recipeRootId"
|
||||
class="btn btn-link o_fp_ri_edit_recipe"
|
||||
title="Edit this step's prompts (target ranges, type, options) in the Simple Recipe Editor."
|
||||
t-on-click="openSimpleEditor">
|
||||
<i class="fa fa-pencil me-1"/> Edit Recipe
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div t-if="state.loading" class="o_fp_ri_loading">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<span class="ms-2">Loading prompts...</span>
|
||||
</div>
|
||||
|
||||
<!-- Instructions block - recipe-author HTML + image gallery shown
|
||||
above the prompt cards so the operator reads context BEFORE
|
||||
entering values. Hidden when neither is authored. -->
|
||||
<div t-if="!state.loading and (state.instructionsHtml or state.instructionImages.length)"
|
||||
class="o_fp_ri_instructions">
|
||||
<div t-if="state.instructionsHtml"
|
||||
class="o_fp_ri_instructions_text"
|
||||
t-out="state.instructionsHtml"/>
|
||||
<div t-if="state.instructionImages.length"
|
||||
class="o_fp_ri_instructions_gallery">
|
||||
<t t-foreach="state.instructionImages" t-as="img" t-key="img.id">
|
||||
<a t-att-href="img.url"
|
||||
target="_blank"
|
||||
class="o_fp_ri_instructions_thumb"
|
||||
t-att-title="img.name">
|
||||
<img t-att-src="img.url" t-att-alt="img.name"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state. Independent t-if (not t-elif) so the
|
||||
instructions block above doesn't break the chain - the
|
||||
cards / empty branch must only depend on loading + rows. -->
|
||||
<div t-if="!state.loading and !state.rows.length" class="o_fp_ri_empty">
|
||||
<p>No measurement prompts on this step.</p>
|
||||
<button class="btn btn-secondary" t-on-click="addAdHocRow">
|
||||
<i class="fa fa-plus me-1"/> Add a measurement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards. Same fix - independent t-if. -->
|
||||
<div t-if="!state.loading and state.rows.length" class="o_fp_ri_cards">
|
||||
<t t-foreach="state.rows" t-as="row" t-key="row_index">
|
||||
<div class="o_fp_ri_card"
|
||||
t-att-class="{ 'o_fp_ri_card_required': row.required }">
|
||||
|
||||
<!-- Card header - prompt name + meta pills -->
|
||||
<div class="o_fp_ri_card_head">
|
||||
<div class="o_fp_ri_prompt">
|
||||
<!-- Authored prompt: read-only label -->
|
||||
<span t-if="row.is_authored"
|
||||
class="o_fp_ri_prompt_label">
|
||||
<span t-esc="row.name"/>
|
||||
<span t-if="row.required" class="o_fp_ri_required_mark" title="Required">*</span>
|
||||
</span>
|
||||
<!-- Ad-hoc prompt: editable -->
|
||||
<input t-else=""
|
||||
type="text"
|
||||
class="o_fp_ri_prompt_input"
|
||||
placeholder="Measurement name…"
|
||||
t-model="row.name"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_ri_meta">
|
||||
<span class="o_fp_ri_pill o_fp_ri_pill_type"
|
||||
t-esc="inputTypeLabel(row)"/>
|
||||
<span t-if="row.target_unit"
|
||||
class="o_fp_ri_pill o_fp_ri_pill_unit"
|
||||
t-esc="row.target_unit"/>
|
||||
</div>
|
||||
|
||||
<button t-if="!row.is_authored"
|
||||
class="o_fp_ri_remove btn btn-link"
|
||||
title="Remove this measurement"
|
||||
t-on-click="() => this.removeRow(row_index)">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Target range hint (any prompt with a target_min /
|
||||
target_max - numeric, pass_fail, etc.). Renders
|
||||
as a small "Target: 0.005 - 0.007 in" pill so the
|
||||
operator can see the spec before they enter
|
||||
readings. -->
|
||||
<div t-if="row.target_min or row.target_max"
|
||||
class="o_fp_ri_target">
|
||||
<i class="fa fa-bullseye me-1"/>
|
||||
<span class="o_fp_ri_target_label">Target</span>
|
||||
<strong class="o_fp_ri_target_value">
|
||||
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> - </t><t t-if="row.target_max" t-esc="row.target_max"/>
|
||||
</strong>
|
||||
<span t-if="row.target_unit" class="o_fp_ri_target_unit" t-esc="row.target_unit"/>
|
||||
</div>
|
||||
|
||||
<!-- Hint text from recipe author -->
|
||||
<div t-if="row.hint" class="o_fp_ri_hint" t-esc="row.hint"/>
|
||||
|
||||
<!-- Card body - live input widget per type -->
|
||||
<div class="o_fp_ri_card_body">
|
||||
|
||||
<!-- Numeric - single value (no range defined) -->
|
||||
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
|
||||
class="o_fp_ri_numeric">
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_number')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_number"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_number')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
<t t-set="hint" t-value="rangeHint(row)"/>
|
||||
<span t-if="hint"
|
||||
class="o_fp_ri_range_hint"
|
||||
t-att-class="'o_fp_ri_range_' + hint.kind"
|
||||
t-esc="hint.text"/>
|
||||
</div>
|
||||
|
||||
<!-- Numeric - dual entry (recipe author defined a
|
||||
min and max target → operator records both
|
||||
observed extremes from their measurements).
|
||||
Constrained to numeric so it doesn't duplicate
|
||||
the pass_fail+range branch above. -->
|
||||
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_min')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_min')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_max')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_max')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set="dhint" t-value="dualRangeHint(row)"/>
|
||||
<span t-if="dhint"
|
||||
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
|
||||
t-att-class="'o_fp_ri_range_' + dhint.kind"
|
||||
t-esc="dhint.text"/>
|
||||
</div>
|
||||
|
||||
<!-- Pass / Fail with range - operator records min
|
||||
+ max measurements first, system suggests the
|
||||
verdict, then operator confirms with PASS/FAIL.
|
||||
This branch fires when the recipe author
|
||||
defined target_min / target_max on a pass_fail
|
||||
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
|
||||
<t t-if="isPassFail(row) and hasRangeEntry(row)">
|
||||
<div class="o_fp_ri_dual">
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_min')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_min')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_max')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_max')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set="dhint" t-value="dualRangeHint(row)"/>
|
||||
<span t-if="dhint"
|
||||
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
|
||||
t-att-class="'o_fp_ri_range_' + dhint.kind"
|
||||
t-esc="dhint.text"/>
|
||||
</div>
|
||||
<t t-set="sugg" t-value="suggestedPassFail(row)"/>
|
||||
<div t-if="sugg" class="o_fp_ri_pf_suggest"
|
||||
t-att-class="'o_fp_ri_pf_suggest_' + sugg">
|
||||
<i t-att-class="sugg === 'pass' ? 'fa fa-check-circle me-1' : 'fa fa-exclamation-triangle me-1'"/>
|
||||
Readings suggest <strong t-esc="sugg.toUpperCase()"/> - confirm below.
|
||||
</div>
|
||||
<div class="o_fp_ri_passfail">
|
||||
<button type="button"
|
||||
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
|
||||
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
|
||||
t-on-click="() => this.onPass(row)">
|
||||
<i class="fa fa-check me-2"/> PASS
|
||||
</button>
|
||||
<button type="button"
|
||||
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
|
||||
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
|
||||
t-on-click="() => this.onFail(row)">
|
||||
<i class="fa fa-times me-2"/> FAIL
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Pass / Fail without range - distinct two-button
|
||||
widget so the operator sees the OUTCOME, not a
|
||||
generic toggle. Active button fills with green
|
||||
(PASS) or red (FAIL); the inactive one stays
|
||||
outlined. -->
|
||||
<div t-if="isPassFail(row) and !hasRangeEntry(row)"
|
||||
class="o_fp_ri_passfail">
|
||||
<button type="button"
|
||||
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
|
||||
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
|
||||
t-on-click="() => this.onPass(row)">
|
||||
<i class="fa fa-check me-2"/> PASS
|
||||
</button>
|
||||
<button type="button"
|
||||
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
|
||||
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
|
||||
t-on-click="() => this.onFail(row)">
|
||||
<i class="fa fa-times me-2"/> FAIL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Generic boolean toggle (Yes / No) -->
|
||||
<label t-if="isBoolean(row)" class="o_fp_ri_toggle">
|
||||
<input type="checkbox" t-model="row.value_boolean"/>
|
||||
<span class="o_fp_ri_toggle_track">
|
||||
<span class="o_fp_ri_toggle_thumb"/>
|
||||
</span>
|
||||
<span class="o_fp_ri_toggle_label"
|
||||
t-esc="row.value_boolean ? 'Yes' : 'No'"/>
|
||||
</label>
|
||||
|
||||
<!-- Date / time -->
|
||||
<input t-if="isDate(row)"
|
||||
type="datetime-local"
|
||||
class="o_fp_ri_input o_fp_ri_input_date"
|
||||
t-model="row.value_date"/>
|
||||
|
||||
<!-- Selection (uses recipe author's selection_options) -->
|
||||
<t t-if="isSelection(row)">
|
||||
<t t-set="opts" t-value="selectionOptions(row)"/>
|
||||
<select t-if="opts.length"
|
||||
class="o_fp_ri_input o_fp_ri_input_select"
|
||||
t-model="row.value_text">
|
||||
<option value="">- choose -</option>
|
||||
<t t-foreach="opts" t-as="opt" t-key="opt">
|
||||
<option t-att-value="opt" t-esc="opt"/>
|
||||
</t>
|
||||
</select>
|
||||
<div t-else="" class="o_fp_ri_select_empty">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
No options configured for this prompt - type a value below.
|
||||
<input type="text"
|
||||
class="o_fp_ri_input o_fp_ri_input_text mt-2"
|
||||
t-model="row.value_text"
|
||||
placeholder="Enter value…"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Signature - distinct affordance so the operator
|
||||
knows initials are required (not free text). -->
|
||||
<div t-if="isSignature(row)" class="o_fp_ri_signature">
|
||||
<i class="fa fa-pencil-square-o o_fp_ri_signature_icon"/>
|
||||
<input type="text"
|
||||
class="o_fp_ri_input o_fp_ri_input_signature"
|
||||
t-model="row.value_text"
|
||||
placeholder="Type your initials (e.g. JD)"
|
||||
maxlength="10"/>
|
||||
</div>
|
||||
|
||||
<!-- Photo upload -->
|
||||
<div t-if="isPhoto(row)" class="o_fp_ri_photo">
|
||||
<div t-if="!row.photo_value" class="o_fp_ri_photo_placeholder">
|
||||
<label class="btn btn-secondary">
|
||||
<i class="fa fa-camera me-2"/> Take or upload photo
|
||||
<input type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden=""
|
||||
t-on-change="(ev) => this.onPhotoChange(row, ev)"/>
|
||||
</label>
|
||||
</div>
|
||||
<div t-else="" class="o_fp_ri_photo_preview">
|
||||
<img t-att-src="photoPreviewSrc(row)" alt="Captured photo"/>
|
||||
<button class="btn btn-sm btn-link text-danger"
|
||||
t-on-click="() => this.onPhotoClear(row)">
|
||||
<i class="fa fa-trash me-1"/> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-point thickness - 5 readings + live avg -->
|
||||
<div t-if="isMulti(row)" class="o_fp_ri_multi">
|
||||
<div class="o_fp_ri_multi_grid">
|
||||
<label>R1
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_1"/>
|
||||
</label>
|
||||
<label>R2
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_2"/>
|
||||
</label>
|
||||
<label>R3
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_3"/>
|
||||
</label>
|
||||
<label>R4
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_4"/>
|
||||
</label>
|
||||
<label>R5
|
||||
<input type="number" t-att-step="stepFor(row)" t-model.number="row.point_5"/>
|
||||
</label>
|
||||
<div class="o_fp_ri_multi_avg">
|
||||
<span class="text-muted">Avg</span>
|
||||
<strong t-esc="multiPointAvg(row)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bath chemistry panel - pH / conc / temp / bath -->
|
||||
<div t-if="isPanel(row)" class="o_fp_ri_panel">
|
||||
<label>pH
|
||||
<input type="number" step="0.01" t-model.number="row.panel_ph"/>
|
||||
</label>
|
||||
<label>Concentration
|
||||
<input type="number" step="0.1" t-model.number="row.panel_concentration"/>
|
||||
</label>
|
||||
<label>Temperature
|
||||
<input type="number" step="1" t-model.number="row.panel_temperature"/>
|
||||
</label>
|
||||
<label>Bath ID
|
||||
<input type="text" t-model="row.panel_bath_id"/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Time (HH:MM:SS) - native time picker with seconds.
|
||||
Mobile/tablet browsers surface the OS time wheel. -->
|
||||
<input t-if="isTimeHms(row)"
|
||||
type="time"
|
||||
step="1"
|
||||
class="o_fp_ri_input o_fp_ri_input_text"
|
||||
t-model="row.value_text"/>
|
||||
|
||||
<!-- Text fallback (text, signature, anything else) -->
|
||||
<input t-if="isText(row)"
|
||||
type="text"
|
||||
class="o_fp_ri_input o_fp_ri_input_text"
|
||||
t-model="row.value_text"
|
||||
placeholder="Enter value…"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Add-row CTA -->
|
||||
<button class="o_fp_ri_add_btn btn btn-link"
|
||||
t-on-click="addAdHocRow">
|
||||
<i class="fa fa-plus me-1"/> Add another measurement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary"
|
||||
t-att-disabled="state.saving or state.loading"
|
||||
t-on-click="onSave">
|
||||
<i t-if="state.saving" class="fa fa-spinner fa-spin me-2"/>
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="onCancel">
|
||||
Cancel
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,12 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
from . import test_qty_received_propagation
|
||||
from . import test_display_wo_name
|
||||
from . import test_blocker_compute
|
||||
from . import test_late_risk_ratio
|
||||
from . import test_active_step_id
|
||||
from . import test_autopause_cron
|
||||
from . import test_post_shop_states
|
||||
from . import test_recipe_cert_suppression
|
||||
from . import test_order_ship_state
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. - License OPL-1
|
||||
"""Plan task P2.3 - fp.job.active_step_id compute."""
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestActiveStepId(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'AS'})
|
||||
self.product = self.env['product.product'].create({'name': 'AS'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/AS',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_no_active_step(self):
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'S1',
|
||||
'sequence': 10,
|
||||
'state': 'ready',
|
||||
})
|
||||
self.job.invalidate_recordset(['active_step_id'])
|
||||
self.assertFalse(self.job.active_step_id.id)
|
||||
|
||||
def test_single_in_progress_step(self):
|
||||
s = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'S1',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
self.job.invalidate_recordset(['active_step_id'])
|
||||
self.assertEqual(self.job.active_step_id.id, s.id)
|
||||
|
||||
def test_multiple_in_progress_picks_lowest_sequence(self):
|
||||
s1 = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'S1',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'S2',
|
||||
'sequence': 20,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
self.job.invalidate_recordset(['active_step_id'])
|
||||
self.assertEqual(self.job.active_step_id.id, s1.id)
|
||||
@@ -1,80 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. - License OPL-1
|
||||
"""Plan task P2.4 - _cron_autopause_stale_steps method."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestAutopauseCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'AP'})
|
||||
self.product = self.env['product.product'].create({'name': 'AP'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/AP',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_stale_step_flips_to_paused(self):
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Stale',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=10),
|
||||
})
|
||||
paused = self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
self.assertGreaterEqual(paused, 1)
|
||||
step.invalidate_recordset(['state'])
|
||||
self.assertEqual(step.state, 'paused')
|
||||
|
||||
def test_fresh_step_unchanged(self):
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Fresh',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=2),
|
||||
})
|
||||
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
step.invalidate_recordset(['state'])
|
||||
self.assertEqual(step.state, 'in_progress')
|
||||
|
||||
def test_long_running_node_exempt(self):
|
||||
node = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Long bake',
|
||||
'long_running': True,
|
||||
'node_type': 'operation',
|
||||
})
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Long',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=20),
|
||||
'recipe_node_id': node.id,
|
||||
})
|
||||
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
step.invalidate_recordset(['state'])
|
||||
self.assertEqual(step.state, 'in_progress')
|
||||
|
||||
def test_threshold_config_parameter_respected(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fp.shopfloor.autopause_threshold_hours', '24',
|
||||
)
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Within 24h',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=10),
|
||||
})
|
||||
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
step.invalidate_recordset(['state'])
|
||||
# 10h < 24h → still in_progress
|
||||
self.assertEqual(step.state, 'in_progress')
|
||||
@@ -1,70 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. - License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestBlockerCompute(TransactionCase):
|
||||
"""fp.job.step.blocker_kind / blocker_reason / blocker_jump_target_*
|
||||
- Gate visualizer source of truth for the OWL GateViz component.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Test Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/T1',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def _make_step(self, name, sequence, state='ready'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': name,
|
||||
'sequence': sequence,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
def test_terminal_step_has_no_blocker(self):
|
||||
step = self._make_step('Done step', 10, state='done')
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
self.assertEqual(step.blocker_reason, '')
|
||||
|
||||
def test_in_progress_step_has_no_blocker(self):
|
||||
step = self._make_step('Running step', 10, state='in_progress')
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
|
||||
def test_solo_ready_step_not_blocked(self):
|
||||
step = self._make_step('Solo', 10, state='ready')
|
||||
# No predecessor → blocker_kind = 'none'
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
self.assertEqual(step.blocker_reason, '')
|
||||
|
||||
def test_predecessor_open_blocks(self):
|
||||
s1 = self._make_step('Earlier', 10, state='in_progress')
|
||||
s2 = self._make_step('Later', 20, state='ready')
|
||||
# If recipe enforces sequential OR step requires predecessor,
|
||||
# blocker_kind should be 'predecessor'. Default depends on job
|
||||
# config; if neither flag triggers we'd see 'none' instead.
|
||||
s2.invalidate_recordset([
|
||||
'blocker_kind', 'blocker_reason',
|
||||
'blocker_jump_target_model', 'blocker_jump_target_id',
|
||||
])
|
||||
if s2._fp_should_block_predecessors():
|
||||
self.assertEqual(s2.blocker_kind, 'predecessor')
|
||||
self.assertIn(s1.name, s2.blocker_reason or '')
|
||||
self.assertEqual(s2.blocker_jump_target_model, 'fp.job.step')
|
||||
self.assertEqual(s2.blocker_jump_target_id, s1.id)
|
||||
else:
|
||||
self.assertEqual(s2.blocker_kind, 'none')
|
||||
|
||||
def test_explicit_requires_predecessor_blocks(self):
|
||||
s1 = self._make_step('Earlier', 10, state='ready')
|
||||
s2 = self._make_step('Later', 20, state='ready')
|
||||
s2.requires_predecessor_done = True
|
||||
s2.invalidate_recordset(['blocker_kind', 'blocker_reason'])
|
||||
self.assertEqual(s2.blocker_kind, 'predecessor')
|
||||
self.assertEqual(s2.blocker_jump_target_id, s1.id)
|
||||
@@ -1,38 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. - License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestDisplayWoName(TransactionCase):
|
||||
"""fp.job.display_wo_name - Tablet/dashboard formatter."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Test Prod'})
|
||||
|
||||
def _make_job(self, name):
|
||||
return self.env['fp.job'].create({
|
||||
'name': name,
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_wh_job_prefix_formatted(self):
|
||||
job = self._make_job('WH/JOB/00001')
|
||||
self.assertEqual(job.display_wo_name, 'WO # 00001')
|
||||
|
||||
def test_wh_job_with_year(self):
|
||||
job = self._make_job('WH/JOB/2026/00042')
|
||||
self.assertEqual(job.display_wo_name, 'WO # 00042')
|
||||
|
||||
def test_plain_numeric(self):
|
||||
job = self._make_job('00123')
|
||||
self.assertEqual(job.display_wo_name, 'WO # 00123')
|
||||
|
||||
def test_falsy_name(self):
|
||||
# New record before save → name is False; computed returns empty
|
||||
job = self.env['fp.job'].new({'name': False})
|
||||
self.assertEqual(job.display_wo_name, '')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,951 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Milestone cascade Phase 1 tests.
|
||||
|
||||
Covers:
|
||||
- all_steps_terminal (Task 2)
|
||||
- _resolve_required_cert_types (Task 3)
|
||||
- _fp_create_certificates (Task 4)
|
||||
- next_milestone_action (Task 5)
|
||||
- action_advance_next_milestone dispatcher (Task 6)
|
||||
- action_mark_delivered cert gate (Task 8)
|
||||
|
||||
See docs/superpowers/plans/2026-05-12-job-milestone-cascade.md.
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestMilestoneCascade(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'CustA'})
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'Widget',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
def _make_step(self, job, name='Step', state='pending'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': name,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
# ---------------- Task 2: all_steps_terminal ----------------------
|
||||
|
||||
def test_all_steps_terminal_false_when_no_steps(self):
|
||||
job = self._make_job()
|
||||
self.assertFalse(job.all_steps_terminal)
|
||||
|
||||
def test_all_steps_terminal_false_when_any_step_pending(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
self._make_step(job, state='pending')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertFalse(job.all_steps_terminal)
|
||||
|
||||
def test_all_steps_terminal_true_when_all_done(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
self._make_step(job, state='done')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertTrue(job.all_steps_terminal)
|
||||
|
||||
def test_all_steps_terminal_true_with_skipped_and_cancelled(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
self._make_step(job, state='skipped')
|
||||
self._make_step(job, state='cancelled')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertTrue(job.all_steps_terminal)
|
||||
|
||||
# ---------------- Task 3: _resolve_required_cert_types -----------
|
||||
|
||||
def _make_part(self, certificate_requirement='inherit'):
|
||||
return self.env['fp.part.catalog'].create({
|
||||
'name': 'PartA',
|
||||
'part_number': 'PN-001-%s' % certificate_requirement,
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_requirement': certificate_requirement,
|
||||
})
|
||||
|
||||
def test_resolve_certs_none_returns_empty(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
def test_resolve_certs_coc_only(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), {'coc'})
|
||||
|
||||
def test_resolve_certs_coc_plus_thickness(self):
|
||||
part = self._make_part(certificate_requirement='coc_thickness')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(
|
||||
job._resolve_required_cert_types(),
|
||||
{'coc', 'thickness_report'},
|
||||
)
|
||||
|
||||
def test_resolve_certs_inherit_falls_back_to_partner(self):
|
||||
part = self._make_part(certificate_requirement='inherit')
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(
|
||||
job._resolve_required_cert_types(),
|
||||
{'coc', 'thickness_report'},
|
||||
)
|
||||
|
||||
def test_resolve_certs_inherit_partner_says_no(self):
|
||||
part = self._make_part(certificate_requirement='inherit')
|
||||
self.partner.x_fc_send_coc = False
|
||||
self.partner.x_fc_send_thickness_report = False
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
def test_resolve_certs_no_part_no_partner_flags(self):
|
||||
self.partner.x_fc_send_coc = False
|
||||
self.partner.x_fc_send_thickness_report = False
|
||||
job = self._make_job()
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
# ---------------- Task 4: _fp_create_certificates -----------------
|
||||
|
||||
def test_create_certs_skips_when_no_required(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertFalse(certs)
|
||||
|
||||
def test_create_certs_coc_only(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
self.assertEqual(certs.certificate_type, 'coc')
|
||||
self.assertEqual(certs.state, 'draft')
|
||||
|
||||
def test_create_certs_coc_plus_thickness(self):
|
||||
part = self._make_part(certificate_requirement='coc_thickness')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 2)
|
||||
self.assertEqual(
|
||||
set(certs.mapped('certificate_type')),
|
||||
{'coc', 'thickness_report'},
|
||||
)
|
||||
|
||||
def test_create_certs_idempotent(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
job._fp_create_certificates() # second call must be no-op
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
|
||||
# ---------------- Task 5: next_milestone_action -------------------
|
||||
|
||||
def test_next_milestone_false_while_steps_running(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='pending')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertFalse(job.next_milestone_action)
|
||||
|
||||
def test_next_milestone_mark_done_when_state_not_done(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
# default state is draft after create
|
||||
self.assertNotEqual(job.state, 'done')
|
||||
self.assertEqual(job.next_milestone_action, 'mark_done')
|
||||
self.assertEqual(job.next_milestone_label, 'Mark Job Done')
|
||||
|
||||
def test_next_milestone_issue_certs_when_draft_cert_exists(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates() # creates draft CoC
|
||||
job.invalidate_recordset([
|
||||
'all_steps_terminal', 'next_milestone_action',
|
||||
])
|
||||
self.assertEqual(job.next_milestone_action, 'issue_certs')
|
||||
|
||||
def test_next_milestone_schedule_delivery_when_no_certs(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
job.invalidate_recordset([
|
||||
'all_steps_terminal', 'next_milestone_action',
|
||||
])
|
||||
self.assertEqual(job.next_milestone_action, 'schedule_delivery')
|
||||
|
||||
def test_next_milestone_closed_when_delivered(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'delivered',
|
||||
})
|
||||
job.delivery_id = delivery.id
|
||||
job.invalidate_recordset([
|
||||
'all_steps_terminal', 'next_milestone_action',
|
||||
])
|
||||
self.assertEqual(job.next_milestone_action, 'closed')
|
||||
|
||||
# ---------------- Task 6: dispatcher ------------------------------
|
||||
|
||||
def test_dispatcher_raises_when_no_action(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='pending') # not terminal
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
with self.assertRaises(UserError):
|
||||
job.action_advance_next_milestone()
|
||||
|
||||
def test_open_draft_certs_returns_filtered_action(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
action = job._action_open_draft_certs()
|
||||
self.assertEqual(action['res_model'], 'fp.certificate')
|
||||
self.assertIn(('state', '=', 'draft'), action['domain'])
|
||||
self.assertIn(('x_fc_job_id', '=', job.id), action['domain'])
|
||||
|
||||
def test_open_draft_delivery_returns_form_when_draft(self):
|
||||
job = self._make_job()
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'draft',
|
||||
})
|
||||
job.delivery_id = delivery.id
|
||||
action = job._action_open_draft_delivery()
|
||||
self.assertEqual(action['res_model'], 'fusion.plating.delivery')
|
||||
self.assertEqual(action.get('res_id'), delivery.id)
|
||||
self.assertEqual(action['view_mode'], 'form')
|
||||
|
||||
def test_open_draft_delivery_falls_back_to_list(self):
|
||||
# Delivery not draft → returns list view filtered to this job.
|
||||
job = self._make_job()
|
||||
self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'delivered',
|
||||
})
|
||||
action = job._action_open_draft_delivery()
|
||||
self.assertEqual(action['view_mode'], 'list,form')
|
||||
self.assertIn(('job_ref', '=', job.name), action['domain'])
|
||||
|
||||
def test_mark_active_raises_without_active_delivery(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job()
|
||||
with self.assertRaises(UserError):
|
||||
job._action_mark_active_delivery_delivered()
|
||||
|
||||
# ---------------- Task 8: cert gate on action_mark_delivered ------
|
||||
|
||||
def test_mark_delivered_blocks_on_draft_certs(self):
|
||||
from odoo.exceptions import UserError
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates() # creates one draft CoC
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'scheduled',
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
delivery.action_mark_delivered()
|
||||
|
||||
def test_mark_delivered_bypass_skips_cert_gate(self):
|
||||
"""With fp_skip_cert_gate=True the gate doesn't raise. Downstream
|
||||
super() chain (notifications, invoicing) may still raise for
|
||||
their own reasons - out of scope for this test."""
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'scheduled',
|
||||
})
|
||||
try:
|
||||
delivery.with_context(
|
||||
fp_skip_cert_gate=True,
|
||||
).action_mark_delivered()
|
||||
except Exception as e:
|
||||
# Cert-gate message must NOT appear. Anything else is fine.
|
||||
self.assertNotIn('draft certificate', str(e))
|
||||
|
||||
def test_mark_delivered_passes_when_cert_issued(self):
|
||||
"""Issuing the cert clears the gate. Downstream chain errors
|
||||
are accepted (delivery PDF render etc. - see test above)."""
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
cert.spec_reference = 'AMS 2404'
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'scheduled',
|
||||
})
|
||||
try:
|
||||
delivery.action_mark_delivered()
|
||||
except Exception as e:
|
||||
self.assertNotIn('draft certificate', str(e))
|
||||
|
||||
|
||||
class TestQtyGate(TransactionCase):
|
||||
"""Step-level quantity gate + partial-qty handling.
|
||||
|
||||
Covers:
|
||||
- button_finish blocks when qty_at_step > 0 AND downstream
|
||||
steps exist (mid-recipe)
|
||||
- manager bypass via fp_skip_qty_gate=True
|
||||
- last-runnable-step exemption (qty_at_step > 0 allowed)
|
||||
- action_complete_one_to_next (Task 3)
|
||||
- auto-move shim on action_finish_and_advance (Task 4)
|
||||
- display_name rename (Task 5)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'QtyWidget',
|
||||
})
|
||||
|
||||
def _make_job(self, qty=3, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': qty,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
def _make_step(self, job, name='Step', sequence=10, state='pending'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': name,
|
||||
'sequence': sequence,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
def _make_two_step_chain(self, qty=3):
|
||||
"""Create a job with two steps; the first is in_progress
|
||||
with `qty` parts parked, the second is ready."""
|
||||
from odoo import fields
|
||||
job = self._make_job(qty=qty)
|
||||
step1 = self._make_step(
|
||||
job, name='Plate', sequence=10, state='in_progress',
|
||||
)
|
||||
step2 = self._make_step(
|
||||
job, name='Bake', sequence=20, state='ready',
|
||||
)
|
||||
step1.date_started = fields.Datetime.now()
|
||||
return job, step1, step2
|
||||
|
||||
# ---------------- button_finish gate ----------------------------
|
||||
|
||||
def test_button_finish_blocks_when_qty_at_step(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 3)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.button_finish()
|
||||
self.assertIn('parts parked', str(exc.exception).replace(
|
||||
'part(s) parked', 'parts parked'))
|
||||
|
||||
def test_button_finish_bypass(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
step1.with_context(fp_skip_qty_gate=True).button_finish()
|
||||
self.assertEqual(step1.state, 'done')
|
||||
|
||||
def test_button_finish_allows_last_step_with_qty(self):
|
||||
"""Last runnable step is exempt - parts complete in place."""
|
||||
from odoo import fields
|
||||
job = self._make_job(qty=5)
|
||||
last = self._make_step(
|
||||
job, name='FinalInspect', sequence=10, state='in_progress',
|
||||
)
|
||||
last.date_started = fields.Datetime.now()
|
||||
last.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(last.qty_at_step, 5)
|
||||
last.button_finish()
|
||||
self.assertEqual(last.state, 'done')
|
||||
|
||||
def test_button_finish_passes_when_qty_zero(self):
|
||||
"""qty_at_step==0 (already moved out) → no gate fires."""
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 2,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 0)
|
||||
step1.button_finish()
|
||||
self.assertEqual(step1.state, 'done')
|
||||
|
||||
|
||||
# ---------------- action_complete_one_to_next -------------------
|
||||
|
||||
def test_complete_one_to_next_records_move(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 3)
|
||||
step1.action_complete_one_to_next()
|
||||
moves = self.env['fp.job.step.move'].search([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
self.assertEqual(len(moves), 1)
|
||||
self.assertEqual(moves.qty_moved, 1)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.state, 'in_progress')
|
||||
self.assertEqual(step1.qty_at_step, 2)
|
||||
|
||||
def test_complete_one_to_next_auto_finishes_on_last(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=1)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 1)
|
||||
step1.action_complete_one_to_next()
|
||||
self.assertEqual(step1.state, 'done')
|
||||
self.assertEqual(step2.state, 'in_progress')
|
||||
|
||||
def test_complete_one_to_next_blocks_when_empty(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 2,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_complete_one_to_next()
|
||||
self.assertIn('nothing to complete', str(exc.exception))
|
||||
|
||||
def test_complete_one_to_next_blocks_when_no_next_step(self):
|
||||
from odoo.exceptions import UserError
|
||||
from odoo import fields
|
||||
job = self._make_job(qty=3)
|
||||
last = self._make_step(
|
||||
job, name='Inspect', sequence=10, state='in_progress',
|
||||
)
|
||||
last.date_started = fields.Datetime.now()
|
||||
last.invalidate_recordset(['qty_at_step'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
last.action_complete_one_to_next()
|
||||
self.assertIn('last runnable step', str(exc.exception))
|
||||
|
||||
def test_complete_one_to_next_blocks_when_not_in_progress(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.state = 'pending'
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_complete_one_to_next()
|
||||
self.assertIn('must be in progress', str(exc.exception))
|
||||
|
||||
|
||||
# ---------------- auto-move shim on Finish & Next ---------------
|
||||
|
||||
def test_finish_and_advance_auto_move_for_qty_1(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=1)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 1)
|
||||
step1.action_finish_and_advance()
|
||||
moves = self.env['fp.job.step.move'].search([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
self.assertEqual(len(moves), 1)
|
||||
self.assertEqual(moves.qty_moved, 1)
|
||||
self.assertEqual(step1.state, 'done')
|
||||
self.assertEqual(step2.state, 'in_progress')
|
||||
|
||||
def test_finish_and_advance_blocks_for_qty_gt_1(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=3)
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(step1.qty_at_step, 3)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_finish_and_advance()
|
||||
self.assertIn("Complete 1", str(exc.exception))
|
||||
self.assertEqual(step1.state, 'in_progress')
|
||||
|
||||
def test_finish_and_advance_passes_for_qty_0(self):
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
self.env['fp.job.step.move'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': 2,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
step1.invalidate_recordset(['qty_at_step'])
|
||||
before = self.env['fp.job.step.move'].search_count([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
step1.action_finish_and_advance()
|
||||
after = self.env['fp.job.step.move'].search_count([
|
||||
('from_step_id', '=', step1.id),
|
||||
])
|
||||
self.assertEqual(after, before)
|
||||
self.assertEqual(step1.state, 'done')
|
||||
|
||||
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
|
||||
from odoo import fields
|
||||
job = self._make_job(qty=5)
|
||||
last = self._make_step(
|
||||
job, name='FinalInspect', sequence=10, state='in_progress',
|
||||
)
|
||||
last.date_started = fields.Datetime.now()
|
||||
last.invalidate_recordset(['qty_at_step'])
|
||||
self.assertEqual(last.qty_at_step, 5)
|
||||
before = self.env['fp.job.step.move'].search_count([])
|
||||
last.action_finish_and_advance()
|
||||
after = self.env['fp.job.step.move'].search_count([])
|
||||
self.assertEqual(after, before)
|
||||
self.assertEqual(last.state, 'done')
|
||||
|
||||
|
||||
# ---------------- display_name rename ----------------------------
|
||||
|
||||
def test_display_name_format(self):
|
||||
job = self._make_job(qty=1)
|
||||
self.assertTrue(job.name.startswith('WH/JOB/'))
|
||||
self.assertTrue(job.display_name.startswith('Work Order # '))
|
||||
suffix = job.name.rsplit('/', 1)[-1]
|
||||
self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
|
||||
|
||||
def test_display_name_no_slash_passthrough(self):
|
||||
"""Manually-named jobs without the sequence prefix display
|
||||
as-is (no rewrite)."""
|
||||
job = self._make_job(qty=1)
|
||||
job.name = 'SmokeJob42'
|
||||
job.invalidate_recordset(['display_name'])
|
||||
self.assertEqual(job.display_name, 'SmokeJob42')
|
||||
|
||||
# ---------------- Move wizard zero-qty regression ----------------
|
||||
|
||||
def test_move_wizard_blocks_zero_qty(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
wiz = self.env['fp.job.step.move.wizard'].create({
|
||||
'job_id': job.id,
|
||||
'from_step_id': step1.id,
|
||||
'to_step_id': step2.id,
|
||||
'qty_moved': 0,
|
||||
'transfer_type': 'step',
|
||||
})
|
||||
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_ids': [(6, 0, [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_ids, 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,
|
||||
})
|
||||
# _fp_is_contract_review_step() matches case-insensitive name
|
||||
# against "contract review" / "qa-005" OR recipe_node_id default_kind.
|
||||
# Setting name='Contract Review' is the simplest reliable trigger
|
||||
# and matches how operators tag the step in production.
|
||||
step_vals = {
|
||||
'job_id': job.id,
|
||||
'name': 'Contract Review' if is_cr else 'Plate',
|
||||
'state': step_state,
|
||||
}
|
||||
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)
|
||||
@@ -1,65 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. - License OPL-1
|
||||
"""Plan task P2.2 - fp.job.late_risk_ratio compute."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestLateRiskRatio(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'LR'})
|
||||
self.product = self.env['product.product'].create({'name': 'LR'})
|
||||
|
||||
def _make_job(self, deadline=None):
|
||||
return self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/LR',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
'date_deadline': deadline,
|
||||
})
|
||||
|
||||
def test_no_deadline_zero(self):
|
||||
job = self._make_job(deadline=False)
|
||||
self.assertEqual(job.late_risk_ratio, 0.0)
|
||||
|
||||
def test_no_open_steps_zero(self):
|
||||
job = self._make_job(deadline=datetime.now() + timedelta(hours=8))
|
||||
self.assertEqual(job.late_risk_ratio, 0.0)
|
||||
|
||||
def test_ratio_above_one_when_overrun(self):
|
||||
job = self._make_job(deadline=datetime.now() + timedelta(hours=2))
|
||||
# One step planned for 240 min, only 120 min left → ratio ~ 2.0
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': 'Long step',
|
||||
'sequence': 10,
|
||||
'state': 'ready',
|
||||
'duration_expected': 240,
|
||||
})
|
||||
job.invalidate_recordset(['late_risk_ratio'])
|
||||
self.assertGreaterEqual(job.late_risk_ratio, 1.5)
|
||||
|
||||
def test_done_steps_dont_count_toward_remaining(self):
|
||||
job = self._make_job(deadline=datetime.now() + timedelta(hours=4))
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': 'Done',
|
||||
'sequence': 10,
|
||||
'state': 'done',
|
||||
'duration_expected': 999,
|
||||
})
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': 'Tiny remaining',
|
||||
'sequence': 20,
|
||||
'state': 'ready',
|
||||
'duration_expected': 30,
|
||||
})
|
||||
job.invalidate_recordset(['late_risk_ratio'])
|
||||
# 30 min remaining vs 240 min to deadline → ratio ~ 0.125
|
||||
self.assertLess(job.late_risk_ratio, 0.3)
|
||||
@@ -1,74 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Order-level ship-readiness gate (spec D4 - ship together).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestOrderShipState(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'ShipWidget'})
|
||||
|
||||
def _make_so(self):
|
||||
return self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
|
||||
def _make_job(self, so, state):
|
||||
return self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'state': state,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
|
||||
def test_ready_single_awaiting_ship_job(self):
|
||||
so = self._make_so()
|
||||
job = self._make_job(so, 'awaiting_ship')
|
||||
info = job._fp_order_ship_state()
|
||||
self.assertTrue(info['ready'])
|
||||
self.assertEqual(info['awaiting_ship_jobs'], job)
|
||||
self.assertEqual(info['not_ready'], [])
|
||||
|
||||
def test_not_ready_with_unfinished_sibling(self):
|
||||
so = self._make_so()
|
||||
j1 = self._make_job(so, 'awaiting_ship')
|
||||
self._make_job(so, 'in_progress')
|
||||
info = j1._fp_order_ship_state()
|
||||
self.assertFalse(info['ready'])
|
||||
self.assertEqual(len(info['not_ready']), 1)
|
||||
|
||||
def test_done_sibling_does_not_block(self):
|
||||
so = self._make_so()
|
||||
j1 = self._make_job(so, 'awaiting_ship')
|
||||
self._make_job(so, 'done')
|
||||
info = j1._fp_order_ship_state()
|
||||
self.assertTrue(info['ready'])
|
||||
|
||||
def test_mark_order_shipped_marks_all_awaiting(self):
|
||||
so = self._make_so()
|
||||
j1 = self._make_job(so, 'awaiting_ship')
|
||||
j2 = self._make_job(so, 'awaiting_ship')
|
||||
j1._fp_mark_order_shipped()
|
||||
self.assertEqual(j1.state, 'done')
|
||||
self.assertEqual(j2.state, 'done')
|
||||
|
||||
def test_mark_order_shipped_blocks_on_unfinished_sibling(self):
|
||||
so = self._make_so()
|
||||
j1 = self._make_job(so, 'awaiting_ship')
|
||||
self._make_job(so, 'in_progress')
|
||||
with self.assertRaises(UserError):
|
||||
j1._fp_mark_order_shipped()
|
||||
@@ -1,100 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Post-shop state transitions (awaiting_cert + awaiting_ship).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestPostShopAdvance(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Widget'})
|
||||
|
||||
def _make_job(self, state='in_progress', **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'state': state,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ===== Task 2 - _fp_check_advance_post_shop helper ==================
|
||||
|
||||
def test_advance_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_check_advance_post_shop'))
|
||||
|
||||
def test_advance_noop_when_state_not_in_progress(self):
|
||||
# confirmed jobs should not be auto-advanced
|
||||
job = self._make_job(state='confirmed')
|
||||
job._fp_check_advance_post_shop()
|
||||
self.assertEqual(job.state, 'confirmed')
|
||||
|
||||
def test_advance_noop_when_no_steps(self):
|
||||
# job with zero steps stays put - nothing to evaluate
|
||||
job = self._make_job(state='in_progress')
|
||||
self.assertFalse(job.step_ids)
|
||||
job._fp_check_advance_post_shop()
|
||||
self.assertEqual(job.state, 'in_progress')
|
||||
|
||||
# ===== Task 3 - cert-issue + cert-void helpers =====================
|
||||
|
||||
def test_advance_after_cert_issue_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_check_advance_after_cert_issue'))
|
||||
|
||||
def test_regress_after_cert_void_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_check_regress_after_cert_void'))
|
||||
|
||||
def test_advance_after_cert_issue_idempotent_when_state_wrong(self):
|
||||
# Calling on a draft job is a no-op.
|
||||
job = self._make_job(state='draft')
|
||||
job._fp_check_advance_after_cert_issue()
|
||||
self.assertEqual(job.state, 'draft')
|
||||
|
||||
# ===== Task 4 - button_finish gates + auto-advance =================
|
||||
|
||||
def test_button_finish_on_last_step_triggers_advance(self):
|
||||
"""Finishing the only step of an in_progress job flips state
|
||||
to awaiting_ship (no cert required for this partner)."""
|
||||
if 'fp.job.step' not in self.env:
|
||||
self.skipTest('fp.job.step not available')
|
||||
job = self._make_job(state='in_progress')
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': 'Final Inspection',
|
||||
'state': 'in_progress',
|
||||
'sequence': 10,
|
||||
})
|
||||
step.button_finish()
|
||||
self.assertEqual(job.state, 'awaiting_ship')
|
||||
|
||||
# ===== Task 5 - button_mark_shipped ================================
|
||||
|
||||
def test_button_mark_shipped_requires_awaiting_ship(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(state='in_progress')
|
||||
with self.assertRaises(UserError):
|
||||
job.button_mark_shipped()
|
||||
|
||||
def test_button_mark_shipped_from_awaiting_ship_lands_done(self):
|
||||
job = self._make_job(state='awaiting_ship')
|
||||
job.button_mark_shipped()
|
||||
self.assertEqual(job.state, 'done')
|
||||
self.assertTrue(job.date_finished)
|
||||
|
||||
# ===== Task 20 - activity helpers ==================================
|
||||
|
||||
def test_schedule_cert_activity_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_schedule_cert_activity'))
|
||||
|
||||
def test_resolve_cert_activities_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_resolve_cert_activities'))
|
||||
@@ -1,155 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Closes the bug surfaced by WO-30043 on 2026-05-20: closing a receiving
|
||||
# did not propagate received_qty to fp.job.qty_received, so the
|
||||
# button_mark_done gate stayed red after the operator had completed
|
||||
# every step of the workflow.
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestQtyReceivedPropagation(TransactionCase):
|
||||
"""fp.receiving close → fp.job.qty_received mirrored per part."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'TestPart'})
|
||||
cls.part_catalog = cls.env['fp.part.catalog'].create({
|
||||
'name': 'Test Part Catalog',
|
||||
'part_number': 'TPC-001',
|
||||
'partner_id': cls.partner.id,
|
||||
})
|
||||
|
||||
def _make_so_with_job(self):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'part_catalog_id': self.part_catalog.id,
|
||||
'qty': 5.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
return so, job
|
||||
|
||||
def _make_receiving(self, so, received_qty=5):
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'partner_id': self.partner.id,
|
||||
'expected_qty': received_qty,
|
||||
'received_qty': received_qty,
|
||||
# box_count_in is required by action_mark_counted's gate.
|
||||
'box_count_in': 1,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id,
|
||||
'part_catalog_id': self.part_catalog.id,
|
||||
'expected_qty': received_qty,
|
||||
'received_qty': received_qty,
|
||||
})
|
||||
return recv
|
||||
|
||||
# ---- propagation on state transitions -----------------------------
|
||||
def test_close_propagates_received_qty_to_job(self):
|
||||
"""The bug: WO-30043 had qty_received=0 after receiving closed."""
|
||||
so, job = self._make_so_with_job()
|
||||
recv = self._make_receiving(so, received_qty=5)
|
||||
# Walk the state machine to closed (draft → counted → closed
|
||||
# after the 2026-05-20 `staged` retirement).
|
||||
recv.action_mark_counted()
|
||||
recv.action_close()
|
||||
# Reload - the hook fires inside _update_so_receiving_status.
|
||||
job.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job.qty_received, 5)
|
||||
|
||||
def test_counted_propagates_partial_qty(self):
|
||||
"""Even a not-yet-closed receiving should mirror what's counted."""
|
||||
so, job = self._make_so_with_job()
|
||||
recv = self._make_receiving(so, received_qty=3)
|
||||
recv.action_mark_counted()
|
||||
job.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job.qty_received, 3)
|
||||
|
||||
def test_no_job_match_is_silent(self):
|
||||
"""If the receiving line's part doesn't match any job, skip
|
||||
without raising - common for receivings without spawned jobs."""
|
||||
# Build a receiving with a part that no job uses.
|
||||
other_part = self.env['fp.part.catalog'].create({
|
||||
'name': 'Orphan',
|
||||
'part_number': 'ORP-001',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'partner_id': self.partner.id,
|
||||
'expected_qty': 1,
|
||||
'received_qty': 1,
|
||||
'box_count_in': 1,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id,
|
||||
'part_catalog_id': other_part.id,
|
||||
'expected_qty': 1,
|
||||
'received_qty': 1,
|
||||
})
|
||||
# Should NOT raise.
|
||||
recv.action_mark_counted()
|
||||
recv.action_close()
|
||||
|
||||
def test_multi_part_so_matches_per_part(self):
|
||||
"""Two jobs on the same SO, each for a different part. Closing
|
||||
a receiving with two lines must mirror to BOTH jobs by part."""
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
part_a = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'part_number': 'A-1', 'partner_id': self.partner.id,
|
||||
})
|
||||
part_b = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'part_number': 'B-1', 'partner_id': self.partner.id,
|
||||
})
|
||||
job_a = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id, 'product_id': self.product.id,
|
||||
'part_catalog_id': part_a.id, 'qty': 3.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
job_b = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id, 'product_id': self.product.id,
|
||||
'part_catalog_id': part_b.id, 'qty': 7.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'partner_id': self.partner.id,
|
||||
'expected_qty': 10,
|
||||
'received_qty': 10,
|
||||
'box_count_in': 2,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id, 'part_catalog_id': part_a.id,
|
||||
'expected_qty': 3, 'received_qty': 3,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id, 'part_catalog_id': part_b.id,
|
||||
'expected_qty': 7, 'received_qty': 7,
|
||||
})
|
||||
recv.action_mark_counted()
|
||||
recv.action_close()
|
||||
job_a.invalidate_recordset(['qty_received'])
|
||||
job_b.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job_a.qty_received, 3)
|
||||
self.assertEqual(job_b.qty_received, 7)
|
||||
|
||||
def test_idempotent_under_repeated_writes(self):
|
||||
"""Hook is safe to call multiple times - value just settles."""
|
||||
so, job = self._make_so_with_job()
|
||||
recv = self._make_receiving(so, received_qty=5)
|
||||
recv.action_mark_counted()
|
||||
# Manually nudge the same state transition again (legitimate
|
||||
# in real life: manager re-opens then re-closes).
|
||||
recv._update_so_receiving_status()
|
||||
recv._update_so_receiving_status()
|
||||
job.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job.qty_received, 5)
|
||||
@@ -1,188 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Recipe-level cert suppression tests (spec 2026-05-27).
|
||||
|
||||
Verifies fp.job._resolve_required_cert_types respects the five
|
||||
recipe-level requires_* Booleans (suppress-only precedence) and the
|
||||
orphan-cert action_issue gate raises on missing attachment.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-27-recipe-cert-toggles-plan.md
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestRecipeCertSuppression(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'AeroCustomer',
|
||||
'is_company': True,
|
||||
})
|
||||
cls.product = cls.env['product.product'].create({'name': 'AeroPart'})
|
||||
# Recipe = top-level fusion.plating.process.node with node_type='recipe'
|
||||
cls.recipe = cls.env['fusion.plating.process.node'].create({
|
||||
'name': 'TestRecipe',
|
||||
'node_type': 'recipe',
|
||||
})
|
||||
|
||||
_part_seq = 0
|
||||
|
||||
def _make_part(self, **kw):
|
||||
# Bump per-test counter so multiple parts in the same test don't
|
||||
# collide on the (partner_id, part_number) uniqueness constraint.
|
||||
type(self)._part_seq += 1
|
||||
vals = {
|
||||
'name': 'PartA',
|
||||
'part_number': 'PN-CERT-%03d' % self._part_seq,
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_requirement': 'inherit',
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.part.catalog'].create(vals)
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'recipe_id': self.recipe.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ---- Test 1: recipe suppresses thickness ----
|
||||
def test_recipe_suppresses_thickness(self):
|
||||
"""Customer wants thickness, recipe says no thickness -> only CoC."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.recipe.requires_thickness_report = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
result = job._resolve_required_cert_types()
|
||||
# CoC stays, thickness is suppressed.
|
||||
self.assertEqual(result, {'coc'})
|
||||
|
||||
# ---- Test 2: recipe suppresses nadcap on commodity part ----
|
||||
def test_recipe_suppresses_nadcap_for_commodity_part(self):
|
||||
"""Customer wants nadcap, commodity recipe says no nadcap -> none."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_nadcap_cert = True
|
||||
self.recipe.requires_nadcap_cert = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
result = job._resolve_required_cert_types()
|
||||
self.assertEqual(result, {'coc'})
|
||||
self.assertNotIn('nadcap_cert', result)
|
||||
|
||||
# ---- Test 3: recipe cannot ADD what customer didn't want ----
|
||||
def test_recipe_cannot_add_certs_customer_didnt_want(self):
|
||||
"""Partner all OFF, recipe all True -> empty (suppress-only)."""
|
||||
self.partner.x_fc_send_coc = False
|
||||
self.partner.x_fc_send_thickness_report = False
|
||||
self.partner.x_fc_send_nadcap_cert = False
|
||||
self.partner.x_fc_send_mill_test = False
|
||||
self.partner.x_fc_send_customer_specific = False
|
||||
# All recipe requires_* default True; do not flip anything.
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
# ---- Test 4: recipe can suppress part-level override ----
|
||||
def test_part_override_coc_recipe_suppresses(self):
|
||||
"""Part says coc, recipe says no coc -> empty (recipe wins)."""
|
||||
self.recipe.requires_coc = False
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
# ---- Test 5: all 3 orphan types propagate when customer wants them ----
|
||||
def test_all_orphan_types_propagate(self):
|
||||
"""All 5 partner toggles ON, recipe default -> 4-element set
|
||||
(thickness collapses into CoC via bundling rule)."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.partner.x_fc_send_nadcap_cert = True
|
||||
self.partner.x_fc_send_mill_test = True
|
||||
self.partner.x_fc_send_customer_specific = True
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
result = job._resolve_required_cert_types()
|
||||
# thickness merges into CoC PDF (bundling rule preserved)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{'coc', 'nadcap_cert', 'mill_test', 'customer_specific'},
|
||||
)
|
||||
self.assertNotIn('thickness_report', result)
|
||||
|
||||
# ---- Test 6: orphan cert blocks Issue without attachment ----
|
||||
def test_orphan_cert_issue_blocks_without_attachment(self):
|
||||
"""Spawn a Nadcap cert with no attachment -> action_issue raises."""
|
||||
# Give the partner an email so the existing email-on-contact gate
|
||||
# doesn't fire first; we want to verify the NEW gate explicitly.
|
||||
self.partner.email = 'qa@aerocustomer.test'
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-NADCAP-001',
|
||||
'certificate_type': 'nadcap_cert',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'contact_partner_ids': [(6, 0, [self.partner.id])],
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'TEST PROCESS',
|
||||
'certified_by_id': self.env.user.id,
|
||||
})
|
||||
# Bypass the QM-only authority gate so the failure we see is the
|
||||
# NEW gate, not the pre-existing authority check.
|
||||
with self.assertRaisesRegex(UserError, 'no PDF attached'):
|
||||
cert.with_context(
|
||||
fp_skip_cert_authority_gate=True
|
||||
).action_issue()
|
||||
|
||||
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
|
||||
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
|
||||
"""A passivation recipe (requires_thickness_report=False) must drop
|
||||
the thickness-data requirement at issue time too - even for a
|
||||
strict-thickness customer. Regression: the recipe-suppression
|
||||
feature updated _resolve_required_cert_types but NOT the
|
||||
action_issue / wizard thickness gate, so passivation CoCs could
|
||||
never be issued (gate demanded Fischerscope data the process
|
||||
cannot produce)."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.partner.x_fc_strict_thickness_required = True
|
||||
self.recipe.requires_thickness_report = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-PASSIVATION',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
# Recipe suppresses thickness -> the shared gate must NOT demand it.
|
||||
self.assertFalse(cert._fp_needs_thickness_data())
|
||||
|
||||
# ---- Test 8: normal recipe still enforces thickness (control) ----
|
||||
def test_normal_recipe_keeps_thickness_issue_gate(self):
|
||||
"""Control for Test 7: a recipe that allows thickness
|
||||
(requires_thickness_report default True) still demands thickness
|
||||
data on the CoC for a thickness customer. Guards against
|
||||
over-suppression weakening real aerospace enforcement."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
# self.recipe.requires_thickness_report stays default True.
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-NORMAL',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
self.assertTrue(cert._fp_needs_thickness_data())
|
||||
@@ -1,201 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- S19 - Surface Fischerscope thickness PDF on the cert form -->
|
||||
<!-- ============================================================ -->
|
||||
<!-- Without this extension the operator has no way to know, -->
|
||||
<!-- before clicking Issue, whether the QC's Fischerscope PDF -->
|
||||
<!-- will be appended to the CoC. After Issue, no indicator that -->
|
||||
<!-- the merged PDF actually contains it. This extension fixes -->
|
||||
<!-- both gaps with a banner + smart button + clickable file. -->
|
||||
<record id="fp_certificate_view_form_jobs"
|
||||
model="ir.ui.view">
|
||||
<field name="name">fp.certificate.form.inherit.jobs</field>
|
||||
<field name="model">fp.certificate</field>
|
||||
<field name="inherit_id"
|
||||
ref="fusion_plating_certificates.fp_certificate_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- 1. Smart button: linked Plating Job, and a separate -->
|
||||
<!-- smart button for the Fischerscope-source QC. -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_open_job"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cogs"
|
||||
invisible="not x_fc_job_id">
|
||||
<field name="x_fc_job_id" widget="statinfo"
|
||||
string="Work Order"/>
|
||||
</button>
|
||||
<button name="action_view_thickness_qc"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-microscope"
|
||||
invisible="not x_fc_thickness_qc_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
decoration-info="x_fc_thickness_status == 'pending'"
|
||||
decoration-success="x_fc_thickness_status == 'merged'"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Fischerscope</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- 2. Banner row above the title - explicit, can't miss. -->
|
||||
<!-- Three states with distinct alert classes. -->
|
||||
<xpath expr="//sheet/div[@class='oe_title']" position="before">
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
<i class="fa fa-info-circle" title="Info"
|
||||
aria-label="Info"/>
|
||||
<strong> Fischerscope thickness PDF is on file.</strong>
|
||||
It will be automatically appended as page 2 of
|
||||
the CoC when you click <strong>Issue</strong>.
|
||||
</div>
|
||||
<div class="alert alert-success" role="alert"
|
||||
invisible="x_fc_thickness_status != 'merged'">
|
||||
<i class="fa fa-check-circle" title="Merged"
|
||||
aria-label="Merged"/>
|
||||
<strong> Fischerscope thickness report merged.</strong>
|
||||
The issued CoC PDF includes the Fischerscope report
|
||||
as page 2 - open the Certificate PDF tab to verify.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
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 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>
|
||||
|
||||
<!-- 3. Thickness Report tab - single place to see/edit
|
||||
every Fischerscope-related field on the cert.
|
||||
Reorganized 2026-05-21:
|
||||
* Status + linked QC at the top (read-only context)
|
||||
* XDAL 600 metadata (operator/product/etc.) editable
|
||||
so manager can correct OCR mistakes
|
||||
* Microscope image preview (auto-extracted from RTF
|
||||
or manually uploaded - either way editable here)
|
||||
* Source files (PDF / non-PDF evidence / source name)
|
||||
* Upload wizard button + help text -->
|
||||
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||
<page string="Thickness Report (Fischerscope)"
|
||||
name="thickness_pdf">
|
||||
|
||||
<!-- Status + QC link (read-only context) -->
|
||||
<group>
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
readonly="1"
|
||||
decoration-muted="x_fc_thickness_status == 'none'"
|
||||
decoration-info="x_fc_thickness_status == 'pending'"
|
||||
decoration-success="x_fc_thickness_status == 'merged'"/>
|
||||
<field name="x_fc_thickness_qc_id" readonly="1"
|
||||
invisible="not x_fc_thickness_qc_id"/>
|
||||
</group>
|
||||
|
||||
<!-- Hints rotate by state -->
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness data has been
|
||||
uploaded yet. Click <strong>Upload Thickness
|
||||
Report</strong> below to drop a `.doc` / `.docx`
|
||||
/ `.rtf` / `.pdf` file straight from the
|
||||
XDAL 600. The wizard parses readings +
|
||||
metadata and fills out the fields on this tab.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
<p>
|
||||
<i class="fa fa-arrow-up"/>
|
||||
Click <strong>Issue</strong> in the header to
|
||||
merge the Fischerscope PDF as page 2 of
|
||||
the CoC. Readings will render inline in the
|
||||
body of the cert either way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload wizard CTA -->
|
||||
<div style="margin: 8px 0;">
|
||||
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
|
||||
type="action"
|
||||
class="btn-primary"
|
||||
string="Upload Thickness Report"
|
||||
context="{'default_certificate_id': id}"
|
||||
invisible="state != 'draft'"/>
|
||||
</div>
|
||||
|
||||
<separator string="XDAL 600 Measurement Context"/>
|
||||
<p class="text-muted small">
|
||||
These values are pulled from the uploaded file
|
||||
and printed on the CoC's thickness section. Edit
|
||||
any field here to override what the parser saw.
|
||||
</p>
|
||||
<group>
|
||||
<group>
|
||||
<field name="x_fc_thickness_equipment"
|
||||
placeholder="Fischerscope XDAL 600"/>
|
||||
<field name="x_fc_thickness_operator"
|
||||
placeholder="Operator initials / name"/>
|
||||
<field name="x_fc_thickness_datetime"/>
|
||||
<field name="x_fc_thickness_measuring_time_sec"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_thickness_product"
|
||||
placeholder="e.g. 2805031 / NiP/Al-alloys 2805030"/>
|
||||
<field name="x_fc_thickness_application"
|
||||
placeholder="e.g. 16 / NiP/Al-alloys"/>
|
||||
<field name="x_fc_thickness_directory"
|
||||
placeholder="XDAL save directory"/>
|
||||
<field name="x_fc_thickness_source_filename"
|
||||
readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Microscope Image"/>
|
||||
<p class="text-muted small">
|
||||
Auto-extracted from RTF uploads (via libwmf) or
|
||||
manually uploaded via the wizard. Drop a new
|
||||
PNG/JPEG here to override.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_thickness_image_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
|
||||
<separator string="Source Files"/>
|
||||
<group>
|
||||
<group string="Fischerscope PDF"
|
||||
invisible="not x_fc_local_thickness_pdf">
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<group string="Non-PDF Evidence (RTF/DOCX)"
|
||||
invisible="not x_fc_local_thickness_evidence_id">
|
||||
<field name="x_fc_local_thickness_evidence_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<group string="QC-side Fischerscope PDF"
|
||||
invisible="not x_fc_thickness_pdf_id">
|
||||
<field name="x_fc_thickness_pdf_id" readonly="1"
|
||||
widget="many2one_binary"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_job_consumption_list" model="ir.ui.view">
|
||||
<field name="name">fp.job.consumption.list</field>
|
||||
<field name="model">fp.job.consumption</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom" default_order="logged_date desc">
|
||||
<field name="logged_date"/>
|
||||
<field name="job_id"/>
|
||||
<field name="step_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="unit_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="total_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
||||
<field name="source"/>
|
||||
<field name="logged_by_id" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_job_consumption_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.consumption.form</field>
|
||||
<field name="model">fp.job.consumption</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="job_id"/>
|
||||
<field name="step_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="product_name"/>
|
||||
<field name="source"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="unit_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="total_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" readonly="1"/>
|
||||
<field name="logged_date"/>
|
||||
<field name="logged_by_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_job_consumption" model="ir.actions.act_window">
|
||||
<field name="name">Job Consumables Log</field>
|
||||
<field name="res_model">fp.job.consumption</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,343 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Adds a "Process Tree" header button + smart-button row to the
|
||||
fp.job form. The fp.job form in core has no button_box yet, so
|
||||
we inject one at the top of the sheet (xpath //sheet position
|
||||
"inside" with a sibling reference at the start).
|
||||
|
||||
Smart buttons appear only when the underlying count is > 0
|
||||
(except Steps, which always shows since every confirmed job
|
||||
has steps). Pattern follows the existing oe_stat_button row
|
||||
from sale.order / mrp.production.
|
||||
|
||||
Process Tree header button is hidden while the job is in draft
|
||||
(no recipe-derived steps yet).
|
||||
-->
|
||||
<record id="view_fp_job_form_jobs_inherit" model="ir.ui.view">
|
||||
<field name="name">fp.job.form.jobs.inherit</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<!-- Heal a WO that was created before the recipe was set
|
||||
on the SO line: re-pull the recipe and build steps.
|
||||
Hidden once steps exist or the job is terminal. -->
|
||||
<button name="action_fp_resync_recipe_from_so" type="object"
|
||||
string="Re-sync Recipe from SO"
|
||||
class="btn-secondary"
|
||||
icon="fa-refresh"
|
||||
invisible="state in ('done', 'cancelled') or step_ids"/>
|
||||
<!-- Phase 1 - Tablet redesign. Opens the JobWorkspace OWL
|
||||
client action focused on this WO. Primary entry point
|
||||
for techs before the Landing kanban (Phase 3) ships;
|
||||
stays as a back-office shortcut after. -->
|
||||
<button name="action_open_workspace" type="object"
|
||||
string="Workspace"
|
||||
class="btn-primary"
|
||||
icon="fa-tablet"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_open_process_tree" type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-sitemap"
|
||||
invisible="state == 'draft'"/>
|
||||
<!-- Steelhead-style "Finish & Next": one click finishes
|
||||
whatever's running and auto-starts the next pending
|
||||
step. Falls back to starting the first step if
|
||||
nothing is running yet. The classic Move wizard is
|
||||
still available via the per-row Move button (used
|
||||
for cross-station moves and rework / scrap). -->
|
||||
<button name="action_finish_current_step" type="object"
|
||||
string="Finish & Next"
|
||||
class="btn-primary"
|
||||
icon="fa-arrow-right"
|
||||
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
|
||||
|
||||
<!-- Milestone cascade (Phase 1). All four share the same
|
||||
dispatcher; visibility is gated on next_milestone_action
|
||||
so only one ever renders at a time. -->
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Job Done"
|
||||
class="btn-success"
|
||||
icon="fa-check-circle"
|
||||
invisible="next_milestone_action != 'mark_done'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Issue Certs"
|
||||
class="btn-primary"
|
||||
icon="fa-certificate"
|
||||
invisible="next_milestone_action != 'issue_certs'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Schedule Delivery"
|
||||
class="btn-primary"
|
||||
icon="fa-truck"
|
||||
invisible="next_milestone_action != 'schedule_delivery'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Shipped"
|
||||
class="btn-success"
|
||||
icon="fa-paper-plane"
|
||||
invisible="next_milestone_action != 'mark_shipped'"
|
||||
groups="fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>
|
||||
<field name="all_steps_terminal" invisible="1"/>
|
||||
<field name="next_milestone_action" invisible="1"/>
|
||||
<button name="action_print_sticker" type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-qrcode"
|
||||
invisible="state == 'draft'"
|
||||
help="Print Sticker"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Sub 14 - Replace the generic Draft/Confirmed/In Progress/Done
|
||||
statusbar with the configurable workflow_state_id bar.
|
||||
Operators see meaningful plating milestones (Received,
|
||||
Inspected, Shipped, etc.) instead of generic Odoo states. -->
|
||||
<xpath expr="//header/field[@name='state']" position="replace">
|
||||
<field name="workflow_state_id"
|
||||
widget="statusbar"
|
||||
options="{'clickable': '0'}"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Surface part / coating / recipe on the header so the
|
||||
floor knows WHAT they're plating without diving into
|
||||
Source. The "Reference Product" line in core is just
|
||||
the FP-SERVICE stub from the SO - relabel it so it
|
||||
doesn't compete with the real part identification. -->
|
||||
<xpath expr="//field[@name='product_id']" position="attributes">
|
||||
<attribute name="string">Service Product</attribute>
|
||||
<attribute name="invisible">part_catalog_id</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="part_catalog_id" string="Part"/>
|
||||
<field name="customer_spec_id" string="Specification" invisible="1"/>
|
||||
<field name="recipe_id" string="Process Recipe" readonly="1"/>
|
||||
</xpath>
|
||||
<!-- Show qty completed alongside total so the partial-qty
|
||||
picture is visible at a glance without opening Move Log. -->
|
||||
<xpath expr="//field[@name='qty']" position="after">
|
||||
<field name="qty_done" string="Qty Done"/>
|
||||
<field name="qty_scrapped" string="Qty Scrapped"
|
||||
invisible="not qty_scrapped"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Replace the bare-bones Steps list with the action-rich
|
||||
manager view. Per-row buttons mirror what an operator
|
||||
sees on the tablet; Running Min ticks on every refresh
|
||||
for the active step. -->
|
||||
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
|
||||
<field name="step_ids" mode="list"
|
||||
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}">
|
||||
<list editable="bottom" create="false" delete="false"
|
||||
decoration-info="state in ('ready', 'in_progress')"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-warning="state == 'paused'"
|
||||
decoration-muted="state in ('skipped', 'cancelled')">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<button name="action_open_quick_look" type="object"
|
||||
title="View step details"
|
||||
icon="fa-info-circle"
|
||||
width="30"
|
||||
class="btn-link o_fp_step_info_btn"/>
|
||||
<field name="name"/>
|
||||
<field name="work_centre_id" optional="show"/>
|
||||
<field name="tank_id" optional="hide"/>
|
||||
<field name="kind" optional="hide"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state in ('ready', 'in_progress')"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-warning="state == 'paused'"
|
||||
decoration-muted="state in ('skipped', 'cancelled')"/>
|
||||
<field name="assigned_user_id" optional="show"/>
|
||||
<field name="duration_expected" optional="show"/>
|
||||
<field name="duration_running_minutes" string="Running Min" optional="show"/>
|
||||
<field name="duration_actual" optional="show"/>
|
||||
<!-- Live qty currently parked at this step. Hits
|
||||
zero once everything has moved on; >0 means
|
||||
the floor still has parts to process here. -->
|
||||
<field name="qty_at_step" string="Qty Here" optional="show"/>
|
||||
<!-- Primary action: state-aware. Pending/ready → Start,
|
||||
in_progress → Finish & Next (auto-advance like
|
||||
Steelhead), paused → Resume. Done / skipped /
|
||||
cancelled rows show no primary. -->
|
||||
<button name="button_start" type="object"
|
||||
string="Start" icon="fa-play"
|
||||
class="btn-link text-success"
|
||||
invisible="state not in ('ready', 'pending')"/>
|
||||
<button name="button_resume" type="object"
|
||||
title="Resume" icon="fa-play-circle"
|
||||
class="btn-link text-success"
|
||||
invisible="state != 'paused'"/>
|
||||
<button name="action_finish_and_advance" type="object"
|
||||
title="Finish & Next" icon="fa-check-circle"
|
||||
class="btn-link o_fp_finish_btn"
|
||||
invisible="state != 'in_progress'"/>
|
||||
<!-- Reset / redo - back to Ready so the step can be
|
||||
run again (mistake, accidental skip, customer
|
||||
redo). Clears finish + sign-off stamps; keeps the
|
||||
start audit + moves. Hidden on ready/pending. -->
|
||||
<button name="button_reset" type="object"
|
||||
title="Reset step (redo)" icon="fa-undo"
|
||||
class="btn-link text-warning"
|
||||
invisible="state in ('ready', 'pending')"/>
|
||||
|
||||
<!-- Secondary actions - small icons only. Pause is
|
||||
only relevant on a running step; Record Inputs
|
||||
stays available so operators can capture
|
||||
measurements without finishing the step;
|
||||
Skip + Move (cross-station) tucked together. -->
|
||||
<button name="button_pause" type="object"
|
||||
title="Pause" icon="fa-pause"
|
||||
class="btn-link text-warning"
|
||||
invisible="state != 'in_progress'"/>
|
||||
<!-- Streaming flow: complete 1 part at a time,
|
||||
move to next step. Hidden when nothing is
|
||||
parked or the step isn't actively running.
|
||||
Auto-finishes when qty_at_step drains to 0. -->
|
||||
<button name="action_complete_one_to_next" type="object"
|
||||
title="Complete 1 → Next (streaming flow)" icon="fa-forward"
|
||||
class="btn-link text-success"
|
||||
invisible="state != 'in_progress' or qty_at_step < 1"/>
|
||||
<button name="action_open_input_wizard" type="object"
|
||||
title="Record Inputs" icon="fa-pencil-square-o"
|
||||
class="btn-link"
|
||||
invisible="state in ('cancelled', 'skipped')"/>
|
||||
<button name="button_skip" type="object"
|
||||
title="Skip step" icon="fa-step-forward"
|
||||
class="btn-link text-muted"
|
||||
invisible="state not in ('pending', 'ready')"/>
|
||||
<button name="action_open_move_wizard" type="object"
|
||||
title="Move… (custom qty / destination)" icon="fa-exchange"
|
||||
class="btn-link text-muted"
|
||||
invisible="state in ('done', 'cancelled', 'skipped', 'pending')"/>
|
||||
</list>
|
||||
</field>
|
||||
</xpath>
|
||||
|
||||
<!-- New tabs in the notebook: Move Log + Time Logs.
|
||||
Both read-only - operators write via the wizards;
|
||||
these tabs are the audit window. -->
|
||||
<xpath expr="//page[@name='source']" position="before">
|
||||
<page string="Move Log" name="move_log">
|
||||
<field name="move_ids" readonly="1">
|
||||
<list create="false" edit="false" delete="false"
|
||||
decoration-info="transfer_type == 'step'"
|
||||
decoration-warning="transfer_type in ('hold', 'rework')"
|
||||
decoration-danger="transfer_type == 'scrap'">
|
||||
<field name="move_datetime"/>
|
||||
<field name="from_step_id"/>
|
||||
<field name="to_step_id"/>
|
||||
<field name="transfer_type" widget="badge"/>
|
||||
<field name="qty_moved"/>
|
||||
<field name="moved_by_user_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Time Logs" name="time_logs">
|
||||
<field name="time_log_ids" readonly="1">
|
||||
<list create="false" edit="false" delete="false">
|
||||
<field name="step_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="date_started"/>
|
||||
<field name="date_finished"/>
|
||||
<field name="duration_minutes"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
<!-- Inject a button_box at the top of the sheet, before the
|
||||
oe_title block. Smart buttons drill into the matching
|
||||
records the way sale.order does. -->
|
||||
<xpath expr="//sheet/div[hasclass('oe_title')]" position="before">
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart"
|
||||
invisible="sale_order_count == 0">
|
||||
<field name="sale_order_count" widget="statinfo"
|
||||
string="Sale Order"/>
|
||||
</button>
|
||||
<button name="action_view_steps" type="object"
|
||||
class="oe_stat_button" icon="fa-list-ol">
|
||||
<field name="step_count" widget="statinfo"
|
||||
string="Steps"/>
|
||||
</button>
|
||||
<button name="action_view_deliveries" type="object"
|
||||
class="oe_stat_button" icon="fa-truck"
|
||||
invisible="delivery_count == 0">
|
||||
<field name="delivery_count" widget="statinfo"
|
||||
string="Delivery"/>
|
||||
</button>
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="invoice_count == 0">
|
||||
<field name="invoice_count" widget="statinfo"
|
||||
string="Invoices"/>
|
||||
</button>
|
||||
<button name="action_view_payments" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
invisible="payment_count == 0">
|
||||
<field name="payment_count" widget="statinfo"
|
||||
string="Payments"/>
|
||||
</button>
|
||||
<button name="action_view_quality_holds" type="object"
|
||||
class="oe_stat_button" icon="fa-pause-circle"
|
||||
invisible="quality_hold_count == 0">
|
||||
<field name="quality_hold_count" widget="statinfo"
|
||||
string="Holds"/>
|
||||
</button>
|
||||
<button name="action_view_certificates" type="object"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="certificate_count == 0">
|
||||
<field name="certificate_count" widget="statinfo"
|
||||
string="Certificates"/>
|
||||
</button>
|
||||
<button name="action_view_timelogs" type="object"
|
||||
class="oe_stat_button" icon="fa-clock-o"
|
||||
invisible="timelog_count == 0">
|
||||
<field name="timelog_count" widget="statinfo"
|
||||
string="Time Logs"/>
|
||||
</button>
|
||||
<button name="action_view_portal_job" type="object"
|
||||
class="oe_stat_button" icon="fa-globe"
|
||||
invisible="portal_job_count == 0">
|
||||
<field name="portal_job_count" widget="statinfo"
|
||||
string="Portal Job"/>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='x_fc_customer_refs']" position="inside">
|
||||
<field name="x_fc_delivery_method"/>
|
||||
<field name="x_fc_ship_via"/>
|
||||
<field name="x_fc_invoice_strategy"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='x_fc_customer_refs']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<!-- Notes group sits awkwardly above the main fields in core; relocate
|
||||
to a notebook tab so the form opens on the operationally relevant
|
||||
fields (customer / part / steps) instead of empty note placeholders. -->
|
||||
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='costs']" position="before">
|
||||
<page string="Masking Refs" name="masking_refs"
|
||||
invisible="not x_fc_masking_attachment_ids">
|
||||
<div class="text-muted mb-2">
|
||||
Masking reference image(s)/PDF(s) attached at order entry (Express).
|
||||
The operator sees these on the masking step in the workstation.
|
||||
</div>
|
||||
<field name="x_fc_masking_attachment_ids" widget="many2many_binary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</page>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='costs']" position="before">
|
||||
<page string="Notes" name="notes">
|
||||
<group>
|
||||
<field name="x_fc_internal_note" nolabel="1"
|
||||
placeholder="Internal note (not shown to customer)…"/>
|
||||
<field name="x_fc_external_note" nolabel="1"
|
||||
placeholder="External note (printed on customer paperwork)…"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,50 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Sub 12 Phase D - quality smart-button row on fp.job. The quality
|
||||
fields (fp_qc_hold_count, fp_qc_check_count, fp_qc_ncr_count,
|
||||
fp_qc_capa_count, fp_qc_rma_count) are defined in fusion_plating_quality
|
||||
via _inherit on fp.job. The view lives here because the button_box
|
||||
container is added by fusion_plating_jobs (this module loads after
|
||||
quality so we can safely reference quality fields).
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_job_form_quality_buttons" model="ir.ui.view">
|
||||
<field name="name">fp.job.form.quality.buttons</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="inherit_id" ref="view_fp_job_form_jobs_inherit"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Hidden at a count of 0 so the row shows only quality work
|
||||
that actually exists on this job (2026-06-04). -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_fp_holds" type="object"
|
||||
class="oe_stat_button" icon="fa-hand-paper-o"
|
||||
invisible="fp_qc_hold_count == 0">
|
||||
<field name="fp_qc_hold_count" widget="statinfo" string="Holds"/>
|
||||
</button>
|
||||
<button name="action_view_fp_checks" type="object"
|
||||
class="oe_stat_button" icon="fa-check-square-o"
|
||||
invisible="fp_qc_check_count == 0">
|
||||
<field name="fp_qc_check_count" widget="statinfo" string="Checks"/>
|
||||
</button>
|
||||
<button name="action_view_fp_ncrs" type="object"
|
||||
class="oe_stat_button" icon="fa-exclamation-triangle"
|
||||
invisible="fp_qc_ncr_count == 0">
|
||||
<field name="fp_qc_ncr_count" widget="statinfo" string="NCRs"/>
|
||||
</button>
|
||||
<button name="action_view_fp_capas" type="object"
|
||||
class="oe_stat_button" icon="fa-wrench"
|
||||
invisible="fp_qc_capa_count == 0">
|
||||
<field name="fp_qc_capa_count" widget="statinfo" string="CAPAs"/>
|
||||
</button>
|
||||
<button name="action_view_fp_rmas" type="object"
|
||||
class="oe_stat_button" icon="fa-undo"
|
||||
invisible="fp_qc_rma_count == 0">
|
||||
<field name="fp_qc_rma_count" widget="statinfo" string="RMAs"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,169 +0,0 @@
|
||||
<?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.
|
||||
|
||||
Read-only "Step Details" quick-look modal. Triggered by clicking a
|
||||
step's name in the embedded step list inside fp.job's form. Bound
|
||||
via context="{'form_view_ref': '...'}" on the parent - see
|
||||
fp_job_form_inherit.xml. The standalone editable form view stays
|
||||
registered for the Job Steps menu / direct navigation.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_job_step_quick_look_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.quick.look.form</field>
|
||||
<field name="model">fp.job.step</field>
|
||||
<field name="priority">50</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Step Details" edit="false" create="false" delete="false">
|
||||
<sheet>
|
||||
<!-- Hidden helper fields used by section visibility
|
||||
conditions below. Without these the empty-state
|
||||
hides for Equipment / Schedule won't evaluate. -->
|
||||
<field name="work_centre_id" invisible="1"/>
|
||||
<field name="tank_id" invisible="1"/>
|
||||
<field name="bath_id" invisible="1"/>
|
||||
<field name="rack_id" invisible="1"/>
|
||||
<field name="duration_expected" invisible="1"/>
|
||||
<field name="duration_actual" invisible="1"/>
|
||||
<field name="assigned_user_id" invisible="1"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
<div class="text-muted">
|
||||
Step #<field name="sequence" readonly="1"/> ·
|
||||
<field name="kind" readonly="1"/> ·
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'in_progress'"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-warning="state == 'paused'"
|
||||
decoration-muted="state in ('skipped','cancelled')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job context - what job is this step part of, who's
|
||||
the customer, what part, how many. The single most
|
||||
useful thing to surface up top so the operator
|
||||
orients themselves before drilling in. -->
|
||||
<group string="Job Context">
|
||||
<group>
|
||||
<field name="job_id" readonly="1" options="{'no_open': True}"/>
|
||||
<field name="quick_look_part_catalog_id" readonly="1"
|
||||
options="{'no_open': True}"
|
||||
invisible="not quick_look_part_catalog_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="quick_look_partner_id" readonly="1"
|
||||
options="{'no_open': True}"
|
||||
invisible="not quick_look_partner_id"/>
|
||||
<field name="quick_look_qty" readonly="1"
|
||||
invisible="not quick_look_qty"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Equipment / Schedule - only render when there's
|
||||
actually something to show. An Inspection step with
|
||||
no tank / bath / time-budget shouldn't display
|
||||
four empty rows of "-" - that's misleading. -->
|
||||
<group invisible="not work_centre_id and not tank_id and not bath_id and not rack_id and not duration_expected and not duration_actual and not assigned_user_id">
|
||||
<group string="Equipment"
|
||||
invisible="not work_centre_id and not tank_id and not bath_id and not rack_id">
|
||||
<field name="work_centre_id" readonly="1"
|
||||
invisible="not work_centre_id"/>
|
||||
<field name="tank_id" readonly="1"
|
||||
invisible="not tank_id"/>
|
||||
<field name="bath_id" readonly="1"
|
||||
invisible="not bath_id"/>
|
||||
<field name="rack_id" readonly="1"
|
||||
invisible="not rack_id"/>
|
||||
</group>
|
||||
<group string="Schedule"
|
||||
invisible="not duration_expected and not duration_actual and not assigned_user_id">
|
||||
<field name="duration_expected" readonly="1"
|
||||
invisible="not duration_expected"/>
|
||||
<field name="duration_actual" readonly="1"
|
||||
invisible="not duration_actual"/>
|
||||
<field name="assigned_user_id" readonly="1"
|
||||
invisible="not assigned_user_id"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Master switch banner -->
|
||||
<field name="quick_look_collect_master" invisible="1"/>
|
||||
<div class="alert alert-warning"
|
||||
role="alert"
|
||||
invisible="quick_look_collect_master or not recipe_node_id">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<strong> Master switch off</strong> - no values will be collected at runtime for this step.
|
||||
</div>
|
||||
|
||||
<!-- Operator Instructions - hide the whole section when
|
||||
the recipe author didn't write any. -->
|
||||
<separator string="Operator Instructions"
|
||||
invisible="not quick_look_instructions"/>
|
||||
<div class="o_fp_quick_look_instructions"
|
||||
invisible="not quick_look_instructions">
|
||||
<field name="quick_look_instructions" nolabel="1" readonly="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Instruction images - visual reference photos /
|
||||
screenshots the recipe author attached to the
|
||||
node. Hidden when none. -->
|
||||
<separator string="Reference Images"
|
||||
invisible="not quick_look_instruction_attachment_ids"/>
|
||||
<field name="quick_look_instruction_attachment_ids"
|
||||
nolabel="1" readonly="1"
|
||||
widget="many2many_binary"
|
||||
invisible="not quick_look_instruction_attachment_ids"/>
|
||||
|
||||
<separator string="Measurement Prompts"/>
|
||||
<field name="quick_look_prompt_ids" nolabel="1" readonly="1">
|
||||
<list create="false" delete="false" edit="false">
|
||||
<field name="collect" widget="boolean_toggle" readonly="1" string="Collect"/>
|
||||
<field name="name"/>
|
||||
<field name="input_type"/>
|
||||
<field name="target_min" optional="show"/>
|
||||
<field name="target_max" optional="show"/>
|
||||
<field name="target_unit" optional="show"/>
|
||||
<field name="required" widget="boolean_toggle" readonly="1"/>
|
||||
<field name="hint" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
<p class="text-muted small"
|
||||
invisible="quick_look_prompt_ids">
|
||||
No measurement prompts authored for this step.
|
||||
</p>
|
||||
|
||||
<separator string="Values Recorded So Far"/>
|
||||
<field name="quick_look_recorded_value_ids" nolabel="1" readonly="1">
|
||||
<list create="false" delete="false" edit="false" default_order="create_date desc">
|
||||
<field name="node_input_id" string="Prompt"/>
|
||||
<field name="value_text" optional="show"/>
|
||||
<field name="value_number" optional="show"/>
|
||||
<field name="value_boolean" optional="hide"/>
|
||||
<field name="value_date" optional="hide"/>
|
||||
<field name="value_attachment_id" optional="hide"/>
|
||||
<field name="create_uid" string="Recorded By"/>
|
||||
<field name="create_date" string="When"/>
|
||||
</list>
|
||||
</field>
|
||||
<p class="text-muted small"
|
||||
invisible="quick_look_recorded_value_ids">
|
||||
No values recorded yet.
|
||||
</p>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_open_full_form" type="object"
|
||||
string="Open Full Form" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Adds the Work Order smart button + header button to fp.receiving so
|
||||
the receiving form mirrors the SO's WO entry point. Header button
|
||||
appears once state == 'closed' and at least one linked fp.job is
|
||||
still open. Smart button is always visible when WOs exist.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_receiving_form_fp_jobs" model="ir.ui.view">
|
||||
<field name="name">fp.receiving.form.fp.jobs</field>
|
||||
<field name="model">fp.receiving</field>
|
||||
<field name="inherit_id" ref="fusion_plating_receiving.view_fp_receiving_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Work Order header button - only after receiving is
|
||||
closed and while at least one job is still open. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||
<button name="action_view_fp_jobs"
|
||||
string="Work Order" type="object"
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||
<!-- Print box stickers for this receiving's work order - one
|
||||
label per tracked box (external = customer copy, internal
|
||||
= shop copy with internal notes). Shown once a WO exists. -->
|
||||
<button name="action_print_external_sticker"
|
||||
string="External Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the customer (external) box sticker(s) - one per box."/>
|
||||
<button name="action_print_internal_sticker"
|
||||
string="Internal Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the shop (internal) box sticker(s) - same layout, internal notes."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order smart button on the button_box (mirrors the
|
||||
one on the SO form). Always visible when count > 0. -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_fp_jobs" type="object"
|
||||
class="oe_stat_button" icon="fa-cogs"
|
||||
invisible="x_fc_fp_job_count == 0">
|
||||
<field name="x_fc_fp_job_count" widget="statinfo" string="WO"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,101 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Phase 3 (Sub 11) - native Production Priorities kanban / list on
|
||||
fp.job.step. Replaces the bridge_mrp version that bound to
|
||||
mrp.workorder. Same UX (drag-drop ordering across work centres,
|
||||
list with handle, badges by state).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_step_priority_kanban" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.priority.kanban</field>
|
||||
<field name="model">fp.job.step</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="work_centre_id" default_order="sequence, id">
|
||||
<field name="name"/>
|
||||
<field name="work_centre_id"/>
|
||||
<field name="job_id"/>
|
||||
<field name="state"/>
|
||||
<field name="sequence"/>
|
||||
<field name="duration_actual"/>
|
||||
<field name="duration_expected"/>
|
||||
<field name="assigned_user_id"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_card oe_kanban_global_click">
|
||||
<div class="o_kanban_record_top mb-0">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="job_id"/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
<div><strong><field name="name"/></strong></div>
|
||||
<div class="text-muted">
|
||||
<t t-if="record.assigned_user_id.raw_value">
|
||||
<field name="assigned_user_id"/>
|
||||
</t>
|
||||
<t t-if="record.duration_actual.raw_value">
|
||||
- <field name="duration_actual" widget="float_time"/> elapsed
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_bottom">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'ready'"
|
||||
decoration-warning="state == 'in_progress'"
|
||||
decoration-muted="state == 'paused'"
|
||||
decoration-success="state == 'done'"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_step_priority_list" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.priority.list</field>
|
||||
<field name="model">fp.job.step</field>
|
||||
<field name="arch" type="xml">
|
||||
<list default_order="sequence, id">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="job_id"/>
|
||||
<field name="work_centre_id"/>
|
||||
<field name="assigned_user_id"/>
|
||||
<field name="duration_expected" widget="float_time"/>
|
||||
<field name="duration_actual" widget="float_time"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'ready'"
|
||||
decoration-warning="state == 'in_progress'"
|
||||
decoration-muted="state == 'paused'"
|
||||
decoration-success="state == 'done'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_step_priority" model="ir.actions.act_window">
|
||||
<field name="name">Production Priorities</field>
|
||||
<field name="res_model">fp.job.step</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="domain">[('state', 'in', ['pending', 'ready', 'in_progress', 'paused'])]</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_fp_step_priority_kanban')}),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_fp_step_priority_list')})]"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_step_priority"
|
||||
name="Production Priorities"
|
||||
parent="fusion_plating.menu_fp_operations"
|
||||
action="action_fp_step_priority"
|
||||
sequence="10"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,141 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Sub 14 - Workflow state catalog UI (admin / Settings).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ====================== List view ====================== -->
|
||||
<record id="view_fp_workflow_state_list" model="ir.ui.view">
|
||||
<field name="name">fp.job.workflow.state.list</field>
|
||||
<field name="model">fp.job.workflow.state</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Workflow States" default_order="sequence, id">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="color" widget="badge"
|
||||
decoration-info="color == 'blue'"
|
||||
decoration-success="color == 'success' or color == 'green'"
|
||||
decoration-warning="color == 'yellow' or color == 'orange'"
|
||||
decoration-danger="color == 'danger'"
|
||||
decoration-muted="color == 'grey'"/>
|
||||
<field name="trigger_default_kinds"/>
|
||||
<field name="trigger_first_step_started"/>
|
||||
<field name="trigger_all_steps_done"/>
|
||||
<field name="block_when_quality_hold"/>
|
||||
<field name="is_initial"/>
|
||||
<field name="is_terminal"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ====================== Form view ====================== -->
|
||||
<record id="view_fp_workflow_state_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.workflow.state.form</field>
|
||||
<field name="model">fp.job.workflow.state</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Workflow State">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="Received"/></h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="code" placeholder="received"/>
|
||||
<field name="sequence"/>
|
||||
<field name="color" widget="badge"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Lifecycle role">
|
||||
<field name="is_initial"/>
|
||||
<field name="is_terminal"/>
|
||||
<field name="block_when_quality_hold"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Trigger Conditions"/>
|
||||
<group>
|
||||
<field name="trigger_default_kinds"
|
||||
placeholder="receiving, inspect"/>
|
||||
<field name="trigger_first_step_started"/>
|
||||
<field name="trigger_all_steps_done"/>
|
||||
</group>
|
||||
|
||||
<!-- Help block - full sheet width, alert-info card so
|
||||
the explanation is readable instead of squeezed
|
||||
into a 2-column form layout. -->
|
||||
<div class="alert alert-info mt-3" role="alert">
|
||||
<h6 class="alert-heading mb-2">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
How triggers combine
|
||||
</h6>
|
||||
<p class="mb-2">
|
||||
A state is <strong>"passed"</strong> when
|
||||
<strong>either</strong>:
|
||||
</p>
|
||||
<ul class="mb-2">
|
||||
<li>
|
||||
The special trigger is true
|
||||
(<code>trigger_first_step_started</code> or
|
||||
<code>trigger_all_steps_done</code>),
|
||||
<strong>OR</strong>
|
||||
</li>
|
||||
<li>
|
||||
Every recipe step matching the listed
|
||||
<code>trigger_default_kinds</code> (or tagged
|
||||
via the per-node override on the recipe) is
|
||||
in <code>done</code> / <code>skipped</code> /
|
||||
<code>cancelled</code> state.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mb-0">
|
||||
<strong>Blocked by Quality Hold:</strong> holds
|
||||
back the advance even if the trigger conditions
|
||||
are met, until all open quality holds on the job
|
||||
are closed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<separator string="Notes"/>
|
||||
<field name="description" nolabel="1"
|
||||
placeholder="What this milestone represents and when it should fire..."/>
|
||||
</sheet>
|
||||
<!-- Chatter - adds activity log + message thread + followers
|
||||
so admins can audit who changed which trigger and when. -->
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ====================== Action + Menu ====================== -->
|
||||
<record id="action_fp_workflow_state" model="ir.actions.act_window">
|
||||
<field name="name">Workflow States</field>
|
||||
<field name="res_model">fp.job.workflow.state</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first workflow state.
|
||||
</p>
|
||||
<p>
|
||||
Workflow states are the milestones that appear on the
|
||||
status bar of every plating job. Each state passes
|
||||
automatically when its trigger conditions are met
|
||||
(recipe step kind finishes, all steps done, etc.).
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Workflow milestones live under "Recipes & Steps" because each
|
||||
state is triggered by a recipe-step kind / per-step override. -->
|
||||
<menuitem id="menu_fp_workflow_state"
|
||||
name="Job Workflow Stages"
|
||||
parent="fusion_plating.menu_fp_config_recipes_steps"
|
||||
action="action_fp_workflow_state"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Add "Work Orders" and "Steps" as children of fusion_plating_shopfloor's
|
||||
Shop Floor menu. We can reference shopfloor's xmlid here because
|
||||
fusion_plating_jobs declares it as a depend.
|
||||
|
||||
Sequences fit between Tablet Station (10) and Bake Windows (20)
|
||||
in shopfloor's existing fp_menu.xml.
|
||||
|
||||
Renamed 2026-05-25 - "All Jobs" → "Work Orders" for consistency
|
||||
with the rest of the UI (SO smart button is "WO", tablet cards
|
||||
show "WO # 00001", KPI tile says "Work Orders"). xmlid kept
|
||||
as menu_fp_jobs_all_jobs so bookmarks and inherits don't break.
|
||||
-->
|
||||
|
||||
<menuitem id="menu_fp_jobs_all_jobs"
|
||||
name="Work Orders"
|
||||
parent="fusion_plating_shopfloor.menu_fp_shopfloor"
|
||||
action="fusion_plating.action_fp_job"
|
||||
sequence="15"/>
|
||||
|
||||
<menuitem id="menu_fp_jobs_steps"
|
||||
name="Steps"
|
||||
parent="fusion_plating_shopfloor.menu_fp_shopfloor"
|
||||
action="fusion_plating.action_fp_job_step"
|
||||
sequence="17"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||
</odoo>
|
||||
@@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="0">
|
||||
<!-- After the shopfloor consolidation (2026-04-24) the shopfloor
|
||||
operator UIs are the canonical native fp.job / fp.job.step
|
||||
consoles. Only bridge_mrp's Production Priorities menu (still
|
||||
bound to mrp.workorder) remains legacy.
|
||||
|
||||
The group_fusion_plating_legacy_menus group is preserved so a
|
||||
site that needs to bring legacy menus back can simply add a
|
||||
user to the group. -->
|
||||
|
||||
<!-- Reset group_ids on the shopfloor menus that used to be
|
||||
hidden - they are now the canonical UIs and should be visible
|
||||
to all users (subject to the original groups= attribute on
|
||||
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
|
||||
</record>
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [])]"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 3 tablet redesign (2026-05-22) - the standalone Plant
|
||||
Overview menu was superseded by the single Workstation entry.
|
||||
The original <delete> directive lived here; it was retired
|
||||
2026-05-25 because the menu row had been gone for weeks and the
|
||||
re-run of `<delete model="ir.ui.menu" ref="...">` against a
|
||||
missing xmlid raises ValueError on every -u. The action record
|
||||
(action_fp_plant_overview) is kept and retargeted to
|
||||
fp_plant_kanban for bookmark back-compat. -->
|
||||
|
||||
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
|
||||
(the bridge module is uninstalled and its menu xmlid no longer
|
||||
resolves). fp.job has its own priority field on the header. -->
|
||||
</odoo>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Add a plating-signature pad to the user preferences dialog.
|
||||
Anchors on the existing HTML 'signature' field (email signature)
|
||||
and adds our binary image-signature right after it. The
|
||||
widget="signature" gives finger / mouse drawing + image upload.
|
||||
-->
|
||||
<record id="view_users_preferences_form_fp_signature" model="ir.ui.view">
|
||||
<field name="name">res.users.preferences.form.fp.signature</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='signature']" position="after">
|
||||
<field name="x_fc_signature_image"
|
||||
widget="signature"
|
||||
string="Plating Signature"
|
||||
options="{'full_name': 'name'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Same field on the full user form (Settings > Users) so admins
|
||||
can review or seed signatures for operators who aren't tech-
|
||||
savvy enough to do it themselves. -->
|
||||
<record id="view_users_form_fp_signature" model="ir.ui.view">
|
||||
<field name="name">res.users.form.fp.signature</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='signature']" position="after">
|
||||
<field name="x_fc_signature_image"
|
||||
widget="signature"
|
||||
string="Plating Signature"
|
||||
options="{'full_name': 'name'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Adds the SO → fp.job smart button so the SO form is a hub for the
|
||||
native job lifecycle (replaces the legacy MO smart button when the
|
||||
use_native_jobs flag is on).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_sale_order_form_fp_jobs" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.fp.jobs</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- After the legacy Manufacturing button (added by bridge_mrp).
|
||||
Hidden at a count of 0 (2026-06-04) so the SO button row only
|
||||
shows what has data. A confirmed plating SO always has >=1 WO,
|
||||
so this only hides the button on drafts/quotations with no job
|
||||
yet; the header "Work Order" button below still reaches jobs
|
||||
when one is open. -->
|
||||
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
|
||||
<button name="action_view_fp_jobs" type="object"
|
||||
class="oe_stat_button" icon="fa-cogs"
|
||||
invisible="x_fc_fp_job_count == 0">
|
||||
<field name="x_fc_fp_job_count" widget="statinfo"
|
||||
string="WO"/>
|
||||
</button>
|
||||
<!-- Sarah/Tom path: SO → Certificates (one click instead -->
|
||||
<!-- of two via the job). Hidden until a cert exists. -->
|
||||
<button name="action_view_fp_certificates" type="object"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="x_fc_fp_certificate_count == 0">
|
||||
<field name="x_fc_fp_certificate_count" widget="statinfo"
|
||||
string="Certificates"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order header action - appears once any linked
|
||||
receiving is closed AND at least one WO is still open.
|
||||
Reuses the existing action_view_fp_jobs smart-button
|
||||
target so single-job SOs land on the form directly. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||
<button name="action_view_fp_jobs"
|
||||
string="Work Order" type="object"
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this order. Hidden automatically once every linked WO is marked Done."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
||||
line under the SO name (the big SO-30000 heading). Only
|
||||
renders once the SO has been confirmed (quote_ref is set
|
||||
on create, parent_number is set on confirm - both
|
||||
needed for the line to make sense).
|
||||
NB: Odoo 19 forbids t-if in standard form views - using
|
||||
`invisible` attribute on the wrapper div instead. -->
|
||||
<xpath expr="//div[hasclass('oe_title')]" position="inside">
|
||||
<field name="x_fc_parent_number" invisible="1"/>
|
||||
<div class="text-muted"
|
||||
style="font-size: 0.9em; margin-top: 4px;"
|
||||
invisible="not x_fc_quote_ref or not x_fc_parent_number">
|
||||
Originally quoted as
|
||||
<field name="x_fc_quote_ref"
|
||||
readonly="1" nolabel="1"
|
||||
class="d-inline"/>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,7 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import fp_job_step_move_wizard
|
||||
from . import fp_job_step_input_wizard
|
||||
from . import fp_cert_issue_wizard
|
||||
@@ -1,741 +0,0 @@
|
||||
# -*- 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 os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum pixel-area for an extracted RTF image to be treated as the
|
||||
# "microscope photo" candidate. Filters out narrow header banners
|
||||
# (~790x203 = 160k pixels) while keeping standard XDAL exports
|
||||
# (~1024x768 = 786k). See CLAUDE.md "entech apt is broken" for the
|
||||
# libwmf install path that makes this possible.
|
||||
_FP_RTF_IMAGE_MIN_AREA = 200_000
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
|
||||
# document order. The hex blob can be interspersed with whitespace
|
||||
# (RTF wraps to 80 cols) - the consumer strips it.
|
||||
_RTF_PICT_WMF_RE = re.compile(
|
||||
r'\{\\pict'
|
||||
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
|
||||
r'\\wmetafile8'
|
||||
r'(?:\\[a-zA-Z]+-?\d*\s?)*'
|
||||
r'\s*([0-9a-fA-F\s]+?)'
|
||||
r'\}',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _fp_extract_rtf_images(raw_bytes):
|
||||
"""Pull all WMF picture blocks out of an RTF, unpack to PNG via
|
||||
libwmf, and return the list of PNG bytes in document order.
|
||||
|
||||
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
|
||||
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
|
||||
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) - it
|
||||
writes a thin SVG and a side-file `*-N.png` per raster block. We
|
||||
keep the PNGs, drop the SVG/WMF temp files.
|
||||
|
||||
Returns [] (not raise) on any tooling/parse failure; the cert
|
||||
issue keeps working even when image extraction can't run.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return []
|
||||
try:
|
||||
text = raw_bytes.decode('latin-1', errors='replace')
|
||||
except Exception:
|
||||
return []
|
||||
blobs = []
|
||||
for m in _RTF_PICT_WMF_RE.finditer(text):
|
||||
hex_blob = re.sub(r'\s+', '', m.group(1))
|
||||
try:
|
||||
blobs.append(bytes.fromhex(hex_blob))
|
||||
except ValueError:
|
||||
continue
|
||||
if not blobs:
|
||||
return []
|
||||
tmpdir = tempfile.mkdtemp(prefix='fp_rtf_wmf_')
|
||||
pngs = []
|
||||
try:
|
||||
for i, wmf in enumerate(blobs):
|
||||
wmf_path = os.path.join(tmpdir, 'pict%d.wmf' % i)
|
||||
svg_path = os.path.join(tmpdir, 'pict%d.svg' % i)
|
||||
with open(wmf_path, 'wb') as fh:
|
||||
fh.write(wmf)
|
||||
try:
|
||||
subprocess.run(
|
||||
['wmf2svg', '-o', svg_path, wmf_path],
|
||||
capture_output=True, timeout=20, check=False,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||
_logger.warning(
|
||||
'wmf2svg unavailable or timed out (%s) - skipping '
|
||||
'RTF image extraction.', e,
|
||||
)
|
||||
return []
|
||||
# wmf2svg writes <basename>-N.png next to the SVG.
|
||||
for fn in sorted(os.listdir(tmpdir)):
|
||||
if fn.startswith('pict%d-' % i) and fn.endswith('.png'):
|
||||
full = os.path.join(tmpdir, fn)
|
||||
with open(full, 'rb') as fh:
|
||||
pngs.append(fh.read())
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return pngs
|
||||
|
||||
|
||||
def _fp_pick_microscope_image(png_bytes_list):
|
||||
"""Pick the largest-area PNG (by pixel count, not file size) from
|
||||
the list - that's almost always the microscope photo. Header
|
||||
banners are wide-but-thin so their pixel area falls below the
|
||||
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
|
||||
when no PNG meets the threshold.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
# Pillow ships with Odoo; this is defensive.
|
||||
return (png_bytes_list[0] if png_bytes_list else None, 0, 0)
|
||||
best = None
|
||||
best_area = 0
|
||||
for png in png_bytes_list:
|
||||
try:
|
||||
with Image.open(io.BytesIO(png)) as im:
|
||||
area = im.width * im.height
|
||||
if area > best_area and area >= _FP_RTF_IMAGE_MIN_AREA:
|
||||
best = (png, im.width, im.height)
|
||||
best_area = area
|
||||
except Exception:
|
||||
continue
|
||||
return best or (None, 0, 0)
|
||||
|
||||
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)', 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]*)')
|
||||
# XDAL 600 header lines - only present on full RTF reports (not on
|
||||
# the .docx body the upstream parser already handled).
|
||||
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_APPLICATION_RE = re.compile(r'Application:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_MTIME_RE = re.compile(r'Measuring\s+time\s+(\d+)\s*sec', re.IGNORECASE)
|
||||
_FISCHER_EQUIPMENT_RE = re.compile(r'(Fischerscope[^\r\n]*XDAL\s*\d+)', re.IGNORECASE)
|
||||
|
||||
|
||||
def _fp_strip_rtf(raw_bytes):
|
||||
"""Best-effort RTF → plain text. RTF is text-based with control
|
||||
words prefixed by `\\` and groups wrapped in `{}`. We need to strip
|
||||
all of those plus the hex-encoded image data so the Fischerscope
|
||||
reading regex hits clean text.
|
||||
|
||||
Not a full parser - meant for the narrow case of XRF/XDAL reports
|
||||
that have a simple body wrapped around an embedded WMF image.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return ''
|
||||
# RTF is ASCII-safe; latin-1 round-trips every byte.
|
||||
text = raw_bytes.decode('latin-1', errors='replace')
|
||||
# Drop destination groups entirely - these are the image data,
|
||||
# font tables, color tables, etc. The pattern `{\* ...}` and other
|
||||
# nested destinations carry binary-ish hex strings we never want.
|
||||
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
|
||||
text = re.sub(r'\{\\fonttbl[^{}]*\}', ' ', text)
|
||||
text = re.sub(r'\{\\colortbl[^{}]*\}', ' ', text)
|
||||
# Pictures: {\pict ...} contains hex image data. The body is the
|
||||
# part between `\pict...goal\d+` and the closing brace of the group.
|
||||
# Easier: nuke anything matching the picture marker through the
|
||||
# next closing brace at the same depth (single-level approximation
|
||||
# - works for FedEx/XRF docs that have one image per pict block).
|
||||
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
|
||||
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
|
||||
# \tx2840, etc. (`\` + letters + optional digits + optional space)
|
||||
text = re.sub(r'\\[A-Za-z]+-?\d*\s?', ' ', text)
|
||||
# Hex escapes (e.g. \'ae for special chars)
|
||||
text = re.sub(r"\\'[0-9a-fA-F]{2}", ' ', text)
|
||||
# Other backslash escapes (`\\`, `\{`, `\}`)
|
||||
text = re.sub(r'\\[^A-Za-z\s]', ' ', text)
|
||||
# Strip remaining braces
|
||||
text = text.replace('{', ' ').replace('}', ' ')
|
||||
# Collapse runs of whitespace so the Fischerscope regex doesn't
|
||||
# have to deal with weird spacing artefacts from the strip pass.
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
return text
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_rtf(raw_bytes):
|
||||
"""Fischerscope XDAL 600 RTF export → same dict shape as the
|
||||
.docx parser. RTF detection is by magic bytes (`{\\rtf`) - the
|
||||
XRF software names the file `.doc` for legacy reasons, but the
|
||||
contents are RTF.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '',
|
||||
'product': '', 'directory': '', 'application': '',
|
||||
'measuring_time_sec': 0, 'equipment': '',
|
||||
'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
text = _fp_strip_rtf(raw_bytes)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)),
|
||||
float(m.group(3)),
|
||||
float(m.group(4)),
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
def _grab(rx):
|
||||
m = rx.search(text)
|
||||
return m.group(1).strip() if m else ''
|
||||
mtime = 0
|
||||
m = _FISCHER_MTIME_RE.search(text)
|
||||
if m:
|
||||
try:
|
||||
mtime = int(m.group(1))
|
||||
except ValueError:
|
||||
mtime = 0
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': _grab(_FISCHER_CALIB_RE),
|
||||
'operator': _grab(_FISCHER_OPERATOR_RE),
|
||||
'date_str': _grab(_FISCHER_DATE_RE),
|
||||
'time_str': _grab(_FISCHER_TIME_RE),
|
||||
'product': _grab(_FISCHER_PRODUCT_RE),
|
||||
'directory': _grab(_FISCHER_DIRECTORY_RE),
|
||||
'application': _grab(_FISCHER_APPLICATION_RE),
|
||||
'measuring_time_sec': mtime,
|
||||
'equipment': _grab(_FISCHER_EQUIPMENT_RE),
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
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')
|
||||
# Optional: microscope/coupon image exported separately from the
|
||||
# XDAL 600. The RTF carries an embedded WMF that the entech host
|
||||
# can't rasterize (no imagemagick/libwmf - see CLAUDE.md "entech
|
||||
# apt is in a broken-deps state"), so the operator exports a PNG
|
||||
# from the XDAL software and uploads it here. Rendered inline on
|
||||
# the CoC's thickness section when present.
|
||||
fischer_image_file = fields.Binary(string='Measurement Image (PNG/JPEG)')
|
||||
fischer_image_filename = fields.Char(string='Image 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',
|
||||
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
|
||||
def _compute_needs_thickness(self):
|
||||
# Delegate to fp.certificate._fp_needs_thickness_data - the single
|
||||
# source of truth shared with the action_issue hard gate - so the
|
||||
# wizard's readiness hint and the gate can never drift. Honours
|
||||
# recipe-level thickness suppression (passivation = no thickness
|
||||
# even if the customer asked).
|
||||
for ln in self:
|
||||
ln.needs_thickness = (
|
||||
ln.cert_id._fp_needs_thickness_data()
|
||||
if ln.cert_id else False
|
||||
)
|
||||
|
||||
@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):
|
||||
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
|
||||
`.doc` - detected by magic bytes; see CLAUDE.md "Fischerscope
|
||||
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
|
||||
+ summary so the operator can verify before issuing."""
|
||||
if not self.fischer_file:
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
except Exception:
|
||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||
return
|
||||
name = (self.fischer_filename or '').lower()
|
||||
is_rtf = raw[:5] == b'{\\rtf' or name.endswith('.rtf')
|
||||
if is_rtf:
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
elif name.endswith('.docx'):
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
else:
|
||||
self.parsed_summary = _(
|
||||
'Non-parseable upload (%s) - file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
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 _write_thickness_metadata_to_cert(self, cert, parsed):
|
||||
"""Persist the Fischerscope header block (operator, product,
|
||||
application, equipment, measuring time, date/time, source
|
||||
filename) onto the cert so the CoC report can render a full
|
||||
report block instead of a bare readings table.
|
||||
"""
|
||||
vals = {}
|
||||
field_map = (
|
||||
('x_fc_thickness_operator', parsed.get('operator')),
|
||||
('x_fc_thickness_product', parsed.get('product')),
|
||||
('x_fc_thickness_directory', parsed.get('directory')),
|
||||
('x_fc_thickness_application', parsed.get('application')),
|
||||
('x_fc_thickness_measuring_time_sec',
|
||||
parsed.get('measuring_time_sec') or 0),
|
||||
('x_fc_thickness_equipment',
|
||||
parsed.get('equipment') or 'Fischerscope XDAL 600'),
|
||||
('x_fc_thickness_source_filename',
|
||||
self.fischer_filename or ''),
|
||||
)
|
||||
for fname, fval in field_map:
|
||||
if fname in cert._fields and fval:
|
||||
vals[fname] = fval
|
||||
# Combine the gauge's date+time and parse to Datetime - try a
|
||||
# few formats since XDAL exports vary (12h vs 24h, with/without
|
||||
# seconds). Best-effort: leave the field blank if no format
|
||||
# matches rather than crashing the cert issue.
|
||||
date_str = (parsed.get('date_str') or '').strip()
|
||||
time_str = (parsed.get('time_str') or '').strip()
|
||||
if date_str and 'x_fc_thickness_datetime' in cert._fields:
|
||||
from datetime import datetime
|
||||
combined = ('%s %s' % (date_str, time_str)).strip()
|
||||
for fmt in (
|
||||
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
|
||||
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
|
||||
'%m/%d/%Y',
|
||||
):
|
||||
try:
|
||||
vals['x_fc_thickness_datetime'] = datetime.strptime(
|
||||
combined, fmt,
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if vals:
|
||||
cert.write(vals)
|
||||
|
||||
def _apply_to_cert(self):
|
||||
"""Write this line's data into the cert.
|
||||
|
||||
Order matters: operator-uploaded PNG must run LAST so it wins
|
||||
over any image the RTF auto-extraction picked. Reverse order
|
||||
(PNG first, then RTF) lets the WMF blow away the explicit
|
||||
operator choice - exactly the bug we just hit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
cert = self.cert_id.sudo()
|
||||
if not self.fischer_file:
|
||||
# Just push manual readings, if any.
|
||||
self._push_readings_to_cert()
|
||||
# PNG-only path: still attach the operator's image upload.
|
||||
self._apply_image_to_cert(cert)
|
||||
return
|
||||
name = (self.fischer_filename or 'fischerscope').lower()
|
||||
calibration = '' # backfilled below if the parser hits
|
||||
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 AND
|
||||
# link the attachment to the cert's evidence slot so the
|
||||
# thickness-required gate recognises it. Without the link,
|
||||
# the gate would still raise (it checks specific fields,
|
||||
# not stray attachments) and rolling back the transaction
|
||||
# would orphan the upload.
|
||||
att = 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,
|
||||
})
|
||||
if 'x_fc_local_thickness_evidence_id' in cert._fields:
|
||||
cert.write({'x_fc_local_thickness_evidence_id': att.id})
|
||||
# Re-parse the file at apply time so the report-header
|
||||
# metadata (operator, product, application, etc.) makes it
|
||||
# onto the cert. Onchange populates reading_line_ids but
|
||||
# not the cert-level fields. Best-effort: any parse hiccup
|
||||
# is logged and we still complete the attachment + readings.
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
is_rtf = raw[:5] == b'{\\rtf'
|
||||
if is_rtf:
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
elif name.endswith('.docx'):
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
else:
|
||||
parsed = None
|
||||
if parsed:
|
||||
self._write_thickness_metadata_to_cert(cert, parsed)
|
||||
calibration = parsed.get('calibration') or ''
|
||||
# WMF image extraction is RTF-only (the .docx path
|
||||
# uses python-docx which already gives PIL-readable
|
||||
# bitmaps; that flow can be added later if needed).
|
||||
if is_rtf and 'x_fc_thickness_image_id' in cert._fields:
|
||||
pngs = _fp_extract_rtf_images(raw)
|
||||
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
|
||||
if img_bytes:
|
||||
img_att = self.env['ir.attachment'].sudo().create({
|
||||
'name': '%s-microscope.png' % (
|
||||
(self.fischer_filename or 'fischerscope')
|
||||
.rsplit('.', 1)[0]
|
||||
),
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(img_bytes),
|
||||
'mimetype': 'image/png',
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({
|
||||
'x_fc_thickness_image_id': img_att.id,
|
||||
})
|
||||
_logger.info(
|
||||
'Cert %s: attached microscope image '
|
||||
'(%dx%d, %d bytes)',
|
||||
cert.name, img_w, img_h, len(img_bytes),
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Cert %s: Fischerscope metadata extraction failed: %s',
|
||||
cert.name, exc,
|
||||
)
|
||||
cert.message_post(body=Markup(_(
|
||||
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||
)) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert(calibration=calibration)
|
||||
# Operator's PNG upload wins over auto-extracted WMF - runs
|
||||
# last so it overwrites x_fc_thickness_image_id if both paths
|
||||
# supplied an image.
|
||||
self._apply_image_to_cert(cert)
|
||||
|
||||
def _apply_image_to_cert(self, cert):
|
||||
"""Attach the operator-uploaded PNG/JPEG and link it to the
|
||||
cert's image slot so the CoC report can render it inline.
|
||||
No-op when nothing was uploaded. Mirrors the evidence-file
|
||||
pattern: file is attached as a regular ir.attachment AND
|
||||
linked to the dedicated field so the report template can
|
||||
find it predictably.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.fischer_image_file or \
|
||||
'x_fc_thickness_image_id' not in cert._fields:
|
||||
return
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_image_filename or 'thickness-image.png',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_image_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({'x_fc_thickness_image_id': att.id})
|
||||
|
||||
def _push_readings_to_cert(self, calibration=''):
|
||||
"""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.
|
||||
Per-reading calibration_std_ref is stamped from the optional
|
||||
`calibration` arg so the printed CoC's calibration line stays
|
||||
accurate even when readings are re-pushed from a fresh upload.
|
||||
"""
|
||||
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
|
||||
if calibration and 'calibration_std_ref' in Reading._fields:
|
||||
vals['calibration_std_ref'] = calibration
|
||||
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))
|
||||
@@ -1,154 +0,0 @@
|
||||
<?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"
|
||||
js_class="fp_cert_issue_wizard_form"
|
||||
class="o_fp_cert_issue_wizard_form">
|
||||
<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 me-1"/>
|
||||
At least one cert still needs thickness data
|
||||
(Fischerscope file or readings).
|
||||
<strong>Click a row, then click
|
||||
<em>Upload your file</em> in the Fischerscope
|
||||
column.</strong>
|
||||
</div>
|
||||
<!-- 2026-05-20: surface the file upload INLINE in the
|
||||
list instead of behind a row-click into a sub-form.
|
||||
Operators kept missing the upload affordance - the
|
||||
list looked like a status display, not an action
|
||||
surface. Adding the binary field as a column lets
|
||||
them drop the Fischerscope file right where they
|
||||
see "Needs Thickness" turned on. The form behind
|
||||
the row click stays as a "details" expansion for
|
||||
per-reading editing after upload. -->
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="false" delete="false">
|
||||
<field name="cert_name" readonly="1"
|
||||
string="Reference"/>
|
||||
<field name="cert_type" readonly="1"
|
||||
string="Type"/>
|
||||
<field name="partner_id" readonly="1"
|
||||
string="Customer"/>
|
||||
<field name="needs_thickness" readonly="1"
|
||||
widget="boolean_toggle"
|
||||
string="Needs Thickness"/>
|
||||
<!-- Upload column. Available on EVERY cert so a
|
||||
thickness report can be attached to any CoC
|
||||
and pulled onto the printed cert (client
|
||||
request 2026-05-28). needs_thickness still
|
||||
drives the "Needs Thickness" badge + the
|
||||
is_ready blocking gate below. Triggers the
|
||||
@onchange-driven .docx/RTF parser. -->
|
||||
<field name="fischer_filename" column_invisible="1"/>
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"
|
||||
widget="binary"
|
||||
string="Fischerscope File (PDF or .docx)"/>
|
||||
<field name="parsed_summary" readonly="1"
|
||||
string="Parsed"
|
||||
optional="show"
|
||||
invisible="not parsed_summary"/>
|
||||
<field name="is_ready" widget="boolean_toggle"
|
||||
readonly="1"
|
||||
string="Ready"
|
||||
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">
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"/>
|
||||
<field name="fischer_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<group string="Measurement Image (Optional)">
|
||||
<field name="fischer_image_file"
|
||||
filename="fischer_image_filename"
|
||||
widget="image"
|
||||
options="{'size': [200, 200]}"/>
|
||||
<field name="fischer_image_filename"
|
||||
invisible="1"/>
|
||||
<div colspan="2" class="text-muted small">
|
||||
Drop a PNG/JPEG of the coupon
|
||||
under the XRF probe (export
|
||||
from the XDAL 600 software's
|
||||
Image menu). Rendered inline on
|
||||
the printed CoC so the customer
|
||||
sees the actual measurement.
|
||||
</div>
|
||||
</group>
|
||||
<div class="alert alert-info"
|
||||
role="alert"
|
||||
invisible="not parsed_summary">
|
||||
<i class="fa fa-check-circle me-1"/>
|
||||
<field name="parsed_summary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<separator string="Thickness Readings"/>
|
||||
<p class="text-muted small">
|
||||
Auto-filled from the .docx upload above.
|
||||
Edit/add rows manually as needed.
|
||||
</p>
|
||||
<field name="reading_line_ids">
|
||||
<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>
|
||||
@@ -1,342 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
"""Backend Step Input Recording wizard.
|
||||
|
||||
Operator-recorded measurements during a step (Soak Clean Time/Temp,
|
||||
ElectroClean Amperage, E-Nickel Plate Temp, Plating Thickness, etc.)
|
||||
that the customer's WO traveler ends with handwritten in pen.
|
||||
|
||||
These values are the per-step `step_input` prompts authored on the
|
||||
recipe node (fp.step.template.input.kind == 'step_input'). On the
|
||||
tablet they're captured via the QC checklist OWL component; the
|
||||
backend wizard gives the manager the same capability without leaving
|
||||
the job form.
|
||||
|
||||
Captured values land on a synthetic `fp.job.step.move` row with
|
||||
transfer_type='step' (an in-place move, no destination change) so the
|
||||
existing CoC chronological QWeb template renders them in the same
|
||||
format as the tablet-captured values - single source of truth for
|
||||
report rendering.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
# Same selection list as fp.step.template.input.input_type so authored
|
||||
# rows + ad-hoc rows pick from the same vocabulary.
|
||||
_FP_INPUT_TYPE_SELECTION = [
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes/No'),
|
||||
('selection', 'Selection'),
|
||||
('date', 'Date / Time'),
|
||||
('signature', 'Signature'),
|
||||
('time_hms', 'Time (HH:MM:SS)'),
|
||||
('time_seconds', 'Time (seconds)'),
|
||||
('temperature', 'Temperature'),
|
||||
('thickness', 'Thickness'),
|
||||
('pass_fail', 'Pass / Fail'),
|
||||
('photo', 'Photo'),
|
||||
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
|
||||
('bath_chemistry_panel', 'Bath Chemistry Panel'),
|
||||
('ph', 'pH'),
|
||||
]
|
||||
|
||||
|
||||
class FpJobStepInputWizard(models.TransientModel):
|
||||
_name = 'fp.job.step.input.wizard'
|
||||
_description = 'Fusion Plating - Step Input Recording (Backend)'
|
||||
|
||||
step_id = fields.Many2one(
|
||||
'fp.job.step', string='Step', required=True, readonly=True,
|
||||
)
|
||||
job_id = fields.Many2one(
|
||||
related='step_id.job_id', string='Job', store=False, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.job.step.input.wizard.line', 'wizard_id',
|
||||
string='Inputs',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
ctx = self.env.context
|
||||
step_id = ctx.get('default_step_id') or ctx.get('active_id')
|
||||
if not step_id:
|
||||
return defaults
|
||||
step = self.env['fp.job.step'].browse(step_id)
|
||||
if not step.exists() or not step.recipe_node_id:
|
||||
return defaults
|
||||
defaults['step_id'] = step.id
|
||||
node = step.recipe_node_id
|
||||
# Sub 12d - master switch - when off, return no input rows.
|
||||
if hasattr(node, 'collect_measurements') and not node.collect_measurements:
|
||||
defaults['line_ids'] = []
|
||||
return defaults
|
||||
# Filter to step_input prompts only - transition inputs go on the
|
||||
# Move wizard, not here. Also filter to collect=True (per-recipe
|
||||
# opt-out, default True).
|
||||
inputs = node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||
if 'collect' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.collect)
|
||||
defaults['line_ids'] = [(0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': inp.name,
|
||||
'input_type': inp.input_type,
|
||||
'target_min': getattr(inp, 'target_min', 0.0) or 0.0,
|
||||
'target_max': getattr(inp, 'target_max', 0.0) or 0.0,
|
||||
'target_unit': getattr(inp, 'target_unit', False) or False,
|
||||
}) for inp in inputs]
|
||||
return defaults
|
||||
|
||||
def action_commit(self):
|
||||
self.ensure_one()
|
||||
if not self.line_ids:
|
||||
raise UserError(_(
|
||||
'Add at least one input row before clicking Record. '
|
||||
'Click "Add a line" in the table above to enter an '
|
||||
'ad-hoc measurement.'
|
||||
))
|
||||
# Ad-hoc rows must have a prompt name - otherwise we can't tell
|
||||
# what was being measured on the audit trail.
|
||||
unnamed = self.line_ids.filtered(
|
||||
lambda l: not l.node_input_id and not (l.name or '').strip()
|
||||
)
|
||||
if unnamed:
|
||||
raise UserError(_(
|
||||
'Every ad-hoc input row needs a Prompt label so the '
|
||||
'audit trail captures what was measured. %s row(s) missing '
|
||||
'a prompt.'
|
||||
) % len(unnamed))
|
||||
# Synthetic in-place move so the chronological CoC template picks
|
||||
# up these values alongside transition-input values without a
|
||||
# second QWeb branch.
|
||||
Move = self.env['fp.job.step.move']
|
||||
move = Move.create({
|
||||
'job_id': self.step_id.job_id.id,
|
||||
'from_step_id': self.step_id.id,
|
||||
'to_step_id': self.step_id.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': int(self.step_id.job_id.qty or 1),
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
ValueModel = self.env['fp.job.step.move.input.value']
|
||||
Attachment = self.env['ir.attachment']
|
||||
captured = 0
|
||||
for line in self.line_ids:
|
||||
if not line._has_value():
|
||||
continue
|
||||
vals = {
|
||||
'move_id': move.id,
|
||||
'node_input_id': line.node_input_id.id or False,
|
||||
'value_text': line.value_text or False,
|
||||
'value_number': line.value_number or 0.0,
|
||||
'value_boolean': line.value_boolean,
|
||||
'value_date': line.value_date or False,
|
||||
}
|
||||
# Sub 12d - composite + photo input types serialise differently.
|
||||
if line.is_photo_type and line.photo_value:
|
||||
att = Attachment.create({
|
||||
'name': line.photo_filename or 'photo.jpg',
|
||||
'datas': line.photo_value,
|
||||
'res_model': 'fp.job.step.move',
|
||||
'res_id': move.id,
|
||||
})
|
||||
vals['value_attachment_id'] = att.id
|
||||
elif line.is_multi_point_type:
|
||||
import json
|
||||
pts = [line.point_1, line.point_2, line.point_3,
|
||||
line.point_4, line.point_5]
|
||||
non_empty = [p for p in pts if p]
|
||||
avg = sum(non_empty) / len(non_empty) if non_empty else 0.0
|
||||
vals['value_text'] = json.dumps({
|
||||
'readings': pts, 'avg': avg,
|
||||
})
|
||||
vals['value_number'] = avg
|
||||
elif line.is_panel_type:
|
||||
import json
|
||||
vals['value_text'] = json.dumps({
|
||||
'ph': line.panel_ph,
|
||||
'concentration': line.panel_concentration,
|
||||
'temperature': line.panel_temperature,
|
||||
'bath_id': line.panel_bath_id or '',
|
||||
})
|
||||
# For ad-hoc rows (no node_input_id), preserve the operator's
|
||||
# typed prompt label in value_text so the chronological CoC
|
||||
# report still shows what was measured. Format: "Prompt: value"
|
||||
if not line.node_input_id and line.name:
|
||||
if vals['value_text']:
|
||||
vals['value_text'] = f"{line.name}: {vals['value_text']}"
|
||||
elif vals['value_number']:
|
||||
vals['value_text'] = (
|
||||
f"{line.name}: {vals['value_number']}"
|
||||
+ (f" {line.target_unit}" if line.target_unit else '')
|
||||
)
|
||||
else:
|
||||
vals['value_text'] = line.name
|
||||
ValueModel.create(vals)
|
||||
captured += 1
|
||||
if captured == 0:
|
||||
move.unlink()
|
||||
raise UserError(_(
|
||||
'Enter at least one value before saving.'
|
||||
))
|
||||
self.step_id.message_post(body=_(
|
||||
'%(n)s step input(s) recorded by %(user)s'
|
||||
) % {'n': captured, 'user': self.env.user.name})
|
||||
|
||||
# When the wizard was opened from "Finish & Next" we re-enter
|
||||
# the step's finish-and-advance flow with a context flag so it
|
||||
# skips the prompt-for-inputs branch and finishes directly.
|
||||
if self.env.context.get('fp_advance_after_save'):
|
||||
return self.step_id.with_context(
|
||||
fp_after_inputs=True,
|
||||
).action_finish_and_advance()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class FpJobStepInputWizardLine(models.TransientModel):
|
||||
_name = 'fp.job.step.input.wizard.line'
|
||||
_description = 'Fusion Plating - Step Input Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.job.step.input.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
# 2026-04-28 fix - node_input_id is optional now so operators can
|
||||
# record ad-hoc measurements when the recipe has no authored prompts
|
||||
# (the screenshot case: a step with zero step_input definitions
|
||||
# rendered an empty wizard with no way to add anything). Authored
|
||||
# prompts pre-fill name + type as readonly; ad-hoc rows are fully
|
||||
# editable.
|
||||
node_input_id = fields.Many2one(
|
||||
'fusion.plating.process.node.input', ondelete='set null',
|
||||
)
|
||||
name = fields.Char(string='Prompt')
|
||||
# 2026-04-28 - convert input_type + target_unit from Char → Selection
|
||||
# so operators pick from the curated dropdown. Free-text led to "kg"
|
||||
# vs "kgs" vs "kilo" inconsistencies on the audit trail.
|
||||
input_type = fields.Selection(
|
||||
_FP_INPUT_TYPE_SELECTION,
|
||||
string='Type',
|
||||
)
|
||||
target_min = fields.Float(string='Min', digits=(16, 6))
|
||||
target_max = fields.Float(string='Max', digits=(16, 6))
|
||||
target_unit = fields.Selection(
|
||||
FP_UOM_SELECTION,
|
||||
string='Unit',
|
||||
help='Pick from the curated list - keeps every step\'s readings '
|
||||
'in the same vocabulary across the shop.',
|
||||
)
|
||||
|
||||
value_text = fields.Char(string='Text')
|
||||
value_number = fields.Float(string='Number')
|
||||
value_boolean = fields.Boolean(string='Yes/No')
|
||||
value_date = fields.Datetime(string='Date / Time')
|
||||
|
||||
# Sub 12d - composite + photo input types
|
||||
photo_value = fields.Binary(string='Photo', attachment=True)
|
||||
photo_filename = fields.Char(string='Photo Filename')
|
||||
point_1 = fields.Float(string='R1')
|
||||
point_2 = fields.Float(string='R2')
|
||||
point_3 = fields.Float(string='R3')
|
||||
point_4 = fields.Float(string='R4')
|
||||
point_5 = fields.Float(string='R5')
|
||||
point_avg = fields.Float(
|
||||
string='Average',
|
||||
compute='_compute_point_avg',
|
||||
store=False,
|
||||
)
|
||||
panel_ph = fields.Float(string='Panel pH')
|
||||
panel_concentration = fields.Float(string='Panel Concentration')
|
||||
panel_temperature = fields.Float(string='Panel Temperature')
|
||||
panel_bath_id = fields.Char(string='Panel Bath ID')
|
||||
|
||||
@api.depends('point_1', 'point_2', 'point_3', 'point_4', 'point_5')
|
||||
def _compute_point_avg(self):
|
||||
for rec in self:
|
||||
pts = [
|
||||
p for p in (
|
||||
rec.point_1, rec.point_2, rec.point_3,
|
||||
rec.point_4, rec.point_5,
|
||||
) if p
|
||||
]
|
||||
rec.point_avg = sum(pts) / len(pts) if pts else 0.0
|
||||
|
||||
is_authored = fields.Boolean(
|
||||
compute='_compute_is_authored',
|
||||
help='True when this row originated from an authored recipe input. '
|
||||
'Drives field readonly state - authored prompts are locked, '
|
||||
'ad-hoc rows are fully editable.',
|
||||
)
|
||||
|
||||
@api.depends('node_input_id')
|
||||
def _compute_is_authored(self):
|
||||
for rec in self:
|
||||
rec.is_authored = bool(rec.node_input_id)
|
||||
|
||||
# ---- Single-column value editor -----------------------------------------
|
||||
# The previous wizard exposed FOUR value columns (text / number /
|
||||
# yes-no / date) - operators saw 9 columns wide and got lost. We
|
||||
# collapse them into one "Value" column whose widget routes to the
|
||||
# right typed field based on input_type. Booleans and dates get
|
||||
# their own dedicated field (still per-row) so the widget behaves
|
||||
# naturally; everything else types into a single value box.
|
||||
|
||||
is_boolean_type = fields.Boolean(
|
||||
compute='_compute_type_flags',
|
||||
)
|
||||
is_date_type = fields.Boolean(
|
||||
compute='_compute_type_flags',
|
||||
)
|
||||
is_numeric_type = fields.Boolean(
|
||||
compute='_compute_type_flags',
|
||||
)
|
||||
|
||||
is_photo_type = fields.Boolean(compute='_compute_type_flags')
|
||||
is_multi_point_type = fields.Boolean(compute='_compute_type_flags')
|
||||
is_panel_type = fields.Boolean(compute='_compute_type_flags')
|
||||
|
||||
@api.depends('input_type')
|
||||
def _compute_type_flags(self):
|
||||
numeric_types = {
|
||||
'number', 'temperature', 'thickness',
|
||||
'time_seconds', 'ph',
|
||||
}
|
||||
for rec in self:
|
||||
it = rec.input_type or 'text'
|
||||
rec.is_boolean_type = it in ('boolean', 'pass_fail')
|
||||
rec.is_date_type = it == 'date'
|
||||
rec.is_numeric_type = it in numeric_types
|
||||
rec.is_photo_type = it == 'photo'
|
||||
rec.is_multi_point_type = it == 'multi_point_thickness'
|
||||
rec.is_panel_type = it == 'bath_chemistry_panel'
|
||||
|
||||
def _has_value(self):
|
||||
self.ensure_one()
|
||||
if self.is_photo_type:
|
||||
return bool(self.photo_value)
|
||||
if self.is_multi_point_type:
|
||||
return any([
|
||||
self.point_1, self.point_2, self.point_3,
|
||||
self.point_4, self.point_5,
|
||||
])
|
||||
if self.is_panel_type:
|
||||
return any([
|
||||
self.panel_ph, self.panel_concentration,
|
||||
self.panel_temperature, self.panel_bath_id,
|
||||
])
|
||||
return any([
|
||||
self.value_text,
|
||||
self.value_number,
|
||||
self.value_boolean,
|
||||
self.value_date,
|
||||
])
|
||||
@@ -1,250 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_job_step_input_wizard_form_v2" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.input.wizard.form.v2</field>
|
||||
<field name="model">fp.job.step.input.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Record Step Inputs">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="step_id" readonly="1"/>
|
||||
<field name="job_id" readonly="1"/>
|
||||
</group>
|
||||
<separator string="Measurements"/>
|
||||
<p class="text-muted" invisible="line_ids">
|
||||
Click <strong>Add a line</strong> to record one or
|
||||
more measurements for this step.
|
||||
</p>
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="is_authored" column_invisible="1"/>
|
||||
<field name="is_boolean_type" column_invisible="1"/>
|
||||
<field name="is_date_type" column_invisible="1"/>
|
||||
<field name="is_numeric_type" column_invisible="1"/>
|
||||
<field name="is_photo_type" column_invisible="1"/>
|
||||
<field name="is_multi_point_type" column_invisible="1"/>
|
||||
<field name="is_panel_type" column_invisible="1"/>
|
||||
<field name="name"
|
||||
string="Measurement"
|
||||
readonly="is_authored"
|
||||
width="220"
|
||||
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"/>
|
||||
<field name="input_type"
|
||||
string="Type"
|
||||
readonly="is_authored"
|
||||
width="120"/>
|
||||
<field name="target_unit"
|
||||
string="Unit"
|
||||
readonly="is_authored"
|
||||
width="100"
|
||||
optional="show"/>
|
||||
<field name="value_number"
|
||||
string="Number"
|
||||
invisible="not is_numeric_type"/>
|
||||
<field name="value_boolean"
|
||||
string="Yes / No"
|
||||
widget="boolean_toggle"
|
||||
invisible="not is_boolean_type"/>
|
||||
<field name="value_date"
|
||||
string="Date / Time"
|
||||
invisible="not is_date_type"/>
|
||||
<field name="value_text"
|
||||
string="Text"
|
||||
invisible="is_numeric_type or is_boolean_type or is_date_type or is_photo_type or is_multi_point_type or is_panel_type"/>
|
||||
<field name="photo_value"
|
||||
string="Photo"
|
||||
widget="image"
|
||||
options="{'preview_image': 'photo_value'}"
|
||||
invisible="not is_photo_type"/>
|
||||
<field name="photo_filename" column_invisible="1"/>
|
||||
<field name="point_1" string="R1"
|
||||
invisible="not is_multi_point_type" optional="show"/>
|
||||
<field name="point_2" string="R2"
|
||||
invisible="not is_multi_point_type" optional="show"/>
|
||||
<field name="point_3" string="R3"
|
||||
invisible="not is_multi_point_type" optional="show"/>
|
||||
<field name="point_4" string="R4"
|
||||
invisible="not is_multi_point_type" optional="hide"/>
|
||||
<field name="point_5" string="R5"
|
||||
invisible="not is_multi_point_type" optional="hide"/>
|
||||
<field name="point_avg" string="Avg" readonly="1"
|
||||
invisible="not is_multi_point_type"/>
|
||||
<field name="panel_ph" string="pH"
|
||||
invisible="not is_panel_type"/>
|
||||
<field name="panel_concentration" string="Conc"
|
||||
invisible="not is_panel_type"/>
|
||||
<field name="panel_temperature" string="Temp"
|
||||
invisible="not is_panel_type"/>
|
||||
<field name="panel_bath_id" string="Bath"
|
||||
invisible="not is_panel_type"/>
|
||||
<field name="target_min" optional="hide"/>
|
||||
<field name="target_max" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_commit" type="object"
|
||||
string="Save" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- v3 - Card-based stacked layout (light + dark mode aware) -->
|
||||
<!-- -->
|
||||
<!-- Replaces v2's wide editable table. Each measurement renders as a -->
|
||||
<!-- card with: prompt name (header), type/unit pills (meta), and ONLY -->
|
||||
<!-- the live input widget for that prompt's type (value). Composite -->
|
||||
<!-- types (multi-point thickness, bath chemistry panel) keep their -->
|
||||
<!-- sub-fields inside the same card via the "extras" grid area. -->
|
||||
<!-- -->
|
||||
<!-- Auto-adapts to custom recipes/steps because default_get on the -->
|
||||
<!-- model already pre-fills line_ids from recipe_node.input_ids. -->
|
||||
<!-- -->
|
||||
<!-- All visual styling lives in -->
|
||||
<!-- static/src/scss/fp_job_step_input_wizard_v3.scss -->
|
||||
<!-- which is registered in BOTH web.assets_backend AND -->
|
||||
<!-- web.assets_web_dark so both themes compile their own palette. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="view_fp_job_step_input_wizard_form_v3" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.input.wizard.form.v3</field>
|
||||
<field name="model">fp.job.step.input.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Record Step Inputs">
|
||||
<sheet class="o_fp_input_wizard_v3">
|
||||
<div class="o_fp_input_header">
|
||||
<h2><field name="step_id" readonly="1" nolabel="1"/></h2>
|
||||
<p class="o_fp_input_subhead">
|
||||
Job <field name="job_id" readonly="1" nolabel="1"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_input_section_title">Measurements</div>
|
||||
|
||||
<p class="o_fp_input_empty_state" invisible="line_ids">
|
||||
No measurement prompts on this step yet.
|
||||
Click <strong>Add a line</strong> below to record an
|
||||
ad-hoc reading.
|
||||
</p>
|
||||
|
||||
<field name="line_ids" class="o_fp_input_card_list" nolabel="1">
|
||||
<list editable="bottom" create="true" delete="true">
|
||||
<!-- Hidden flag fields - drive value-cell visibility -->
|
||||
<field name="is_authored" column_invisible="1"/>
|
||||
<field name="is_boolean_type" column_invisible="1"/>
|
||||
<field name="is_date_type" column_invisible="1"/>
|
||||
<field name="is_numeric_type" column_invisible="1"/>
|
||||
<field name="is_photo_type" column_invisible="1"/>
|
||||
<field name="is_multi_point_type" column_invisible="1"/>
|
||||
<field name="is_panel_type" column_invisible="1"/>
|
||||
<field name="point_avg" column_invisible="1"/>
|
||||
<field name="photo_filename" column_invisible="1"/>
|
||||
|
||||
<!-- Card header - prompt name (large, bold via SCSS) -->
|
||||
<field name="name"
|
||||
string="Measurement"
|
||||
readonly="is_authored"
|
||||
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"
|
||||
class="o_fp_iw_prompt"/>
|
||||
|
||||
<!-- Meta - type + unit rendered as pills (top-right).
|
||||
Distinct classes so each pill lands in its own
|
||||
grid column (otherwise they stack on top of
|
||||
each other and the labels overlap). -->
|
||||
<field name="input_type"
|
||||
string="Type"
|
||||
readonly="is_authored"
|
||||
class="o_fp_iw_meta o_fp_iw_meta_type"/>
|
||||
<field name="target_unit"
|
||||
string="Unit"
|
||||
readonly="is_authored"
|
||||
class="o_fp_iw_meta o_fp_iw_meta_unit"
|
||||
optional="show"/>
|
||||
|
||||
<!-- Hidden by default - operator can opt in via the cog menu
|
||||
if they want to see/edit target ranges per row -->
|
||||
<field name="target_min" optional="hide"/>
|
||||
<field name="target_max" optional="hide"/>
|
||||
|
||||
<!-- Mutually exclusive value widgets - only the one
|
||||
matching the row's input_type renders -->
|
||||
<field name="value_number"
|
||||
string="Value"
|
||||
invisible="not is_numeric_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="value_boolean"
|
||||
string="Value"
|
||||
widget="boolean_toggle"
|
||||
invisible="not is_boolean_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="value_date"
|
||||
string="Value"
|
||||
invisible="not is_date_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="value_text"
|
||||
string="Value"
|
||||
invisible="is_numeric_type or is_boolean_type or is_date_type or is_photo_type or is_multi_point_type or is_panel_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="photo_value"
|
||||
string="Photo"
|
||||
widget="image"
|
||||
options="{'preview_image': 'photo_value'}"
|
||||
invisible="not is_photo_type"
|
||||
class="o_fp_iw_value"/>
|
||||
|
||||
<!-- Composite type 1: Multi-Point Thickness - 5 readings -->
|
||||
<field name="point_1" string="R1"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="show"/>
|
||||
<field name="point_2" string="R2"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="show"/>
|
||||
<field name="point_3" string="R3"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="show"/>
|
||||
<field name="point_4" string="R4"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="hide"/>
|
||||
<field name="point_5" string="R5"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="hide"/>
|
||||
|
||||
<!-- Composite type 2: Bath Chemistry Panel -->
|
||||
<field name="panel_ph" string="pH"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
<field name="panel_concentration" string="Conc"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
<field name="panel_temperature" string="Temp"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
<field name="panel_bath_id" string="Bath"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_commit" type="object"
|
||||
string="Save" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_job_step_input_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Record Step Inputs</field>
|
||||
<field name="res_model">fp.job.step.input.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fp_job_step_input_wizard_form_v3"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,374 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
"""Backend Move-to-Next wizard.
|
||||
|
||||
Mirrors the tablet's Move Parts dialog (`fusion_plating_shopfloor`'s
|
||||
`move_parts_dialog.js`) so a manager running the whole job from the
|
||||
backend form on a low-staffing day captures the same chain-of-custody
|
||||
record the operator would create from the tablet - same `fp.job.step.move`
|
||||
row + same `transition_input_value_ids` snapshot, same chatter trail,
|
||||
same downstream report rendering.
|
||||
|
||||
Compliance prompts (transition inputs authored on the recipe node)
|
||||
appear as editable rows on the wizard. Submit creates the move log,
|
||||
finishes the from-step if it's still in_progress, and starts the
|
||||
to-step.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# Mirror the Selection on the Record Inputs wizard so both dialogs use
|
||||
# the same Type vocabulary.
|
||||
_FP_INPUT_TYPE_SELECTION = [
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes/No'),
|
||||
('selection', 'Selection'),
|
||||
('date', 'Date / Time'),
|
||||
('signature', 'Signature'),
|
||||
('time_hms', 'Time (HH:MM:SS)'),
|
||||
('time_seconds', 'Time (seconds)'),
|
||||
('temperature', 'Temperature'),
|
||||
('thickness', 'Thickness'),
|
||||
('pass_fail', 'Pass / Fail'),
|
||||
]
|
||||
|
||||
|
||||
class FpJobStepMoveWizard(models.TransientModel):
|
||||
_name = 'fp.job.step.move.wizard'
|
||||
_description = 'Fusion Plating - Move Step Wizard (Backend)'
|
||||
|
||||
job_id = fields.Many2one('fp.job', string='Job', required=True, readonly=True)
|
||||
from_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='From Step',
|
||||
required=True,
|
||||
domain="[('job_id', '=', job_id)]",
|
||||
)
|
||||
to_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='To Step',
|
||||
required=True,
|
||||
domain="[('job_id', '=', job_id), ('id', '!=', from_step_id)]",
|
||||
help='Defaults to the next sequenced step on this job.',
|
||||
)
|
||||
transfer_type = fields.Selection(
|
||||
[
|
||||
('step', 'Step'),
|
||||
('hold', 'Hold'),
|
||||
('scrap', 'Scrap'),
|
||||
('rework', 'Rework'),
|
||||
('split', 'Split'),
|
||||
('return', 'Return'),
|
||||
],
|
||||
string='Transfer Type', default='step', required=True,
|
||||
)
|
||||
qty_moved = fields.Integer(string='Qty Moved', required=True, default=1)
|
||||
to_location = fields.Selection(
|
||||
[
|
||||
('global', 'Global'),
|
||||
('quarantine', 'Quarantine'),
|
||||
('staging_a', 'Staging A'),
|
||||
('staging_b', 'Staging B'),
|
||||
('shipping_dock', 'Shipping Dock'),
|
||||
('scrap_bin', 'Scrap Bin'),
|
||||
],
|
||||
string='To Location', default='global',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
finish_from_step = fields.Boolean(
|
||||
string='Finish From-Step',
|
||||
default=True,
|
||||
help='If the from-step is still in progress, finishing it on move '
|
||||
'closes the timelog and stamps the audit trail.',
|
||||
)
|
||||
start_to_step = fields.Boolean(
|
||||
string='Start To-Step',
|
||||
default=True,
|
||||
help='If the to-step is ready, start it after the move so the '
|
||||
'next operator picks up an in-progress step.',
|
||||
)
|
||||
input_value_ids = fields.One2many(
|
||||
'fp.job.step.move.wizard.input',
|
||||
'wizard_id',
|
||||
string='Compliance Prompts',
|
||||
help='Authored transition inputs from the to-step\'s recipe node. '
|
||||
'Capture the operator\'s answers - they snapshot to '
|
||||
'fp.job.step.move.input.value when the wizard commits.',
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
ctx = self.env.context
|
||||
from_step_id = ctx.get('default_from_step_id') or ctx.get('active_id')
|
||||
if from_step_id and self.env.context.get('active_model') != 'fp.job.step':
|
||||
# Came from job form button - active_id is the job, not the step
|
||||
from_step_id = ctx.get('default_from_step_id')
|
||||
if from_step_id:
|
||||
from_step = self.env['fp.job.step'].browse(from_step_id)
|
||||
if from_step.exists():
|
||||
defaults['from_step_id'] = from_step.id
|
||||
defaults['job_id'] = from_step.job_id.id
|
||||
# Default to "qty currently here", not "job total". A job
|
||||
# already mid-flight may have parts split across steps;
|
||||
# pre-filling with the full job qty would silently let
|
||||
# the operator move more than is actually parked here.
|
||||
# Fall back to job qty when qty_at_step is 0 (e.g.
|
||||
# opened on a fresh step before any movement).
|
||||
qty_here = int(from_step.qty_at_step or 0)
|
||||
defaults['qty_moved'] = qty_here or int(from_step.job_id.qty or 1)
|
||||
# Next sequenced step that isn't done/cancelled
|
||||
next_step = self.env['fp.job.step'].search([
|
||||
('job_id', '=', from_step.job_id.id),
|
||||
('sequence', '>', from_step.sequence),
|
||||
('state', 'not in', ('done', 'cancelled', 'skipped')),
|
||||
], order='sequence asc, id asc', limit=1)
|
||||
if next_step:
|
||||
defaults['to_step_id'] = next_step.id
|
||||
# Pre-seed input_value_ids from authored prompts on
|
||||
# both ends of the move so programmatic creators
|
||||
# (script tests, RPC clients) get them too -
|
||||
# @api.onchange only fires in interactive UI.
|
||||
seen = set()
|
||||
rows = []
|
||||
if from_step.recipe_node_id:
|
||||
inputs = from_step.recipe_node_id.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(
|
||||
lambda i: i.kind == 'step_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Step Input)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
if next_step.recipe_node_id:
|
||||
inputs = next_step.recipe_node_id.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(
|
||||
lambda i: i.kind == 'transition_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Transition)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
if rows:
|
||||
defaults['input_value_ids'] = rows
|
||||
return defaults
|
||||
|
||||
@api.onchange('to_step_id', 'from_step_id')
|
||||
def _onchange_to_step_seed_inputs(self):
|
||||
"""Seed prompt rows from BOTH
|
||||
|
||||
* the to-step's recipe node `transition_input` prompts -
|
||||
authored compliance fields fired on move-in.
|
||||
* the from-step's recipe node `step_input` prompts -
|
||||
measurements that should be captured BEFORE leaving the
|
||||
from-step (operator answers "what did you actually run?"
|
||||
while the data is fresh).
|
||||
|
||||
2026-04-28 fix - previously only transition_input was pulled,
|
||||
which left the section empty for steps where the author only
|
||||
defined step_input prompts. Operators tried to record inputs
|
||||
at move time and got an unfillable form.
|
||||
"""
|
||||
for wiz in self:
|
||||
wiz.input_value_ids = [(5, 0, 0)]
|
||||
seen = set()
|
||||
rows = []
|
||||
# 1. From-step's step_input prompts - measurements captured
|
||||
# while finalising the step.
|
||||
if wiz.from_step_id and wiz.from_step_id.recipe_node_id:
|
||||
from_node = wiz.from_step_id.recipe_node_id
|
||||
inputs = from_node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Step Input)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
# 2. To-step's transition_input prompts - compliance gates
|
||||
# fired on entry to the next step.
|
||||
if wiz.to_step_id and wiz.to_step_id.recipe_node_id:
|
||||
to_node = wiz.to_step_id.recipe_node_id
|
||||
inputs = to_node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'transition_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Transition)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
wiz.input_value_ids = rows
|
||||
|
||||
# ==================================================================
|
||||
def action_commit(self):
|
||||
self.ensure_one()
|
||||
if not self.from_step_id or not self.to_step_id:
|
||||
raise UserError(_('Pick both From and To steps before moving.'))
|
||||
|
||||
# Partial-qty guards. The operator can't move more than is
|
||||
# parked at the from-step, and zero/negative is meaningless.
|
||||
# Self-loop moves (input recording) bypass the upper bound
|
||||
# because they don't move qty.
|
||||
if self.qty_moved <= 0:
|
||||
raise UserError(_(
|
||||
'Qty Moved must be at least 1. Use Skip on the step row '
|
||||
'instead if no parts are being processed.'
|
||||
))
|
||||
is_self_loop = (self.from_step_id == self.to_step_id)
|
||||
if not is_self_loop:
|
||||
qty_here = int(self.from_step_id.qty_at_step or 0)
|
||||
if qty_here > 0 and self.qty_moved > qty_here:
|
||||
raise UserError(_(
|
||||
'Cannot move %(req)s parts - only %(here)s currently '
|
||||
'parked at "%(step)s". Adjust Qty Moved or split '
|
||||
'across multiple moves.'
|
||||
) % {
|
||||
'req': self.qty_moved,
|
||||
'here': qty_here,
|
||||
'step': self.from_step_id.name,
|
||||
})
|
||||
|
||||
Move = self.env['fp.job.step.move']
|
||||
move = Move.create({
|
||||
'job_id': self.job_id.id,
|
||||
'from_step_id': self.from_step_id.id,
|
||||
'to_step_id': self.to_step_id.id,
|
||||
'transfer_type': self.transfer_type,
|
||||
'qty_moved': self.qty_moved,
|
||||
'qty_available_at_move': self.qty_moved,
|
||||
'to_location': self.to_location,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
|
||||
# Snapshot captured prompt values into fp.job.step.move.input.value.
|
||||
ValueModel = self.env['fp.job.step.move.input.value']
|
||||
for line in self.input_value_ids:
|
||||
if not line._has_value():
|
||||
continue
|
||||
vals = {
|
||||
'move_id': move.id,
|
||||
'node_input_id': line.node_input_id.id or False,
|
||||
'value_text': line.value_text or False,
|
||||
'value_number': line.value_number or 0.0,
|
||||
'value_boolean': line.value_boolean,
|
||||
'value_date': line.value_date or False,
|
||||
}
|
||||
# Ad-hoc rows (no node_input_id) - preserve the operator's typed
|
||||
# prompt label in value_text so the chronological CoC report
|
||||
# still shows what was measured.
|
||||
if not line.node_input_id and line.name:
|
||||
if vals['value_text']:
|
||||
vals['value_text'] = f"{line.name}: {vals['value_text']}"
|
||||
elif vals['value_number']:
|
||||
vals['value_text'] = f"{line.name}: {vals['value_number']}"
|
||||
else:
|
||||
vals['value_text'] = line.name
|
||||
ValueModel.create(vals)
|
||||
|
||||
# Finish from-step if requested AND it's still running.
|
||||
if self.finish_from_step and self.from_step_id.state == 'in_progress':
|
||||
self.from_step_id.button_finish()
|
||||
|
||||
# Start to-step if requested AND it's ready/paused.
|
||||
if self.start_to_step and self.to_step_id.state in ('ready', 'paused', 'pending'):
|
||||
# Auto-promote pending → ready when manager moves into it
|
||||
if self.to_step_id.state == 'pending':
|
||||
self.to_step_id.state = 'ready'
|
||||
self.to_step_id.button_start()
|
||||
|
||||
# Surface the new move on the job's chatter so anyone watching
|
||||
# the job form sees the activity in real time.
|
||||
self.job_id.message_post(body=_(
|
||||
'Moved %(qty)s parts: %(from)s → %(to)s by %(user)s'
|
||||
) % {
|
||||
'qty': self.qty_moved,
|
||||
'from': self.from_step_id.name,
|
||||
'to': self.to_step_id.name,
|
||||
'user': self.env.user.name,
|
||||
})
|
||||
|
||||
if self.notes:
|
||||
move.message_post(body=self.notes)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class FpJobStepMoveWizardInput(models.TransientModel):
|
||||
"""Repeater row mirroring fp.job.step.move.input.value.
|
||||
|
||||
Lives on the wizard so the operator/manager fills these inline,
|
||||
then `action_commit` snapshots them into the real model. Keeping
|
||||
a transient mirror means the wizard form can be filled, cancelled,
|
||||
and reopened without polluting the chain-of-custody audit log.
|
||||
|
||||
2026-04-28 - `node_input_id` is now optional so operators can add
|
||||
ad-hoc input rows directly from the Move dialog (operator types
|
||||
the prompt label + value). Authored prompts still pre-fill
|
||||
name + type as readonly; ad-hoc rows are fully editable. Same
|
||||
pattern as the standalone Record Inputs wizard."""
|
||||
_name = 'fp.job.step.move.wizard.input'
|
||||
_description = 'Fusion Plating - Move Wizard Input Row'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.job.step.move.wizard',
|
||||
required=True, ondelete='cascade',
|
||||
)
|
||||
node_input_id = fields.Many2one(
|
||||
'fusion.plating.process.node.input',
|
||||
string='Prompt',
|
||||
ondelete='set null',
|
||||
)
|
||||
name = fields.Char(string='Prompt')
|
||||
input_type = fields.Selection(
|
||||
_FP_INPUT_TYPE_SELECTION,
|
||||
string='Type',
|
||||
)
|
||||
value_text = fields.Char(string='Text Value')
|
||||
value_number = fields.Float(string='Number Value')
|
||||
value_boolean = fields.Boolean(string='Yes/No')
|
||||
value_date = fields.Datetime(string='Date / Time')
|
||||
|
||||
is_authored = fields.Boolean(
|
||||
compute='_compute_is_authored',
|
||||
help='True when this row originated from an authored recipe input. '
|
||||
'Drives field readonly state - authored prompts are locked, '
|
||||
'ad-hoc rows are fully editable.',
|
||||
)
|
||||
|
||||
@api.depends('node_input_id')
|
||||
def _compute_is_authored(self):
|
||||
for rec in self:
|
||||
rec.is_authored = bool(rec.node_input_id)
|
||||
|
||||
def _has_value(self):
|
||||
self.ensure_one()
|
||||
return any([
|
||||
self.value_text,
|
||||
self.value_number,
|
||||
self.value_boolean,
|
||||
self.value_date,
|
||||
])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user