From 6b7b44264a6bf8a66808906d0373d42a3ef8c414 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 10 May 2026 10:25:12 -0400 Subject: [PATCH] changes --- .DS_Store | Bin 14340 -> 14340 bytes AGENTS.md | 94 +++++ fusion_helpdesk/__manifest__.py | 2 +- .../static/description/help_icon.png | Bin 0 -> 34844 bytes .../src/xml/fusion_helpdesk_systray.xml | 2 +- fusion_plating/CLAUDE.md | 1 + fusion_plating/fusion_plating/__manifest__.py | 2 +- .../controllers/simple_recipe_controller.py | 100 ++++- .../fusion_plating/models/fp_process_node.py | 50 ++- .../static/src/js/simple_recipe_editor.js | 80 ++++ .../static/src/scss/simple_recipe_editor.scss | 158 +++++++- .../static/src/xml/simple_recipe_editor.xml | 63 +++- .../views/fp_process_node_views.xml | 16 + .../__manifest__.py | 3 +- .../migrations/19.0.18.11.0/post-migrate.py | 21 ++ .../models/fp_part_catalog.py | 100 ++++- .../models/fp_quote_configurator.py | 9 +- .../security/ir.model.access.csv | 2 + .../src/scss/fp_part_process_composer.scss | 14 +- .../views/fp_part_catalog_views.xml | 90 ++--- .../views/res_partner_views.xml | 1 - .../wizard/__init__.py | 1 + .../wizard/fp_direct_order_line.py | 7 +- .../wizard/fp_direct_order_wizard.py | 41 +- .../wizard/fp_direct_order_wizard_views.xml | 9 +- .../wizard/fp_part_catalog_import_wizard.py | 2 - .../wizard/fp_part_revision_bump_wizard.py | 168 +++++++++ .../fp_part_revision_bump_wizard_views.xml | 78 ++++ .../fusion_plating_invoicing/__manifest__.py | 2 +- .../models/account_move.py | 22 +- .../models/res_partner.py | 21 +- .../models/sale_order.py | 31 +- .../views/sale_order_views.xml | 5 +- .../fusion_plating_jobs/__manifest__.py | 11 +- .../controllers/record_inputs.py | 47 ++- .../fusion_plating_jobs/models/__init__.py | 2 +- .../fusion_plating_jobs/models/fp_job_step.py | 142 ++++++- .../models/res_config_settings.py | 23 -- .../fusion_plating_jobs/models/res_users.py | 40 ++ .../fusion_plating_jobs/models/sale_order.py | 62 ++-- .../static/src/js/fp_record_inputs_dialog.js | 349 ++++++++++++++++-- .../src/scss/fp_record_inputs_dialog.scss | 293 ++++++++++++++- .../src/xml/fp_record_inputs_dialog.xml | 197 ++++++++-- .../tests/test_fp_job_extensions.py | 14 +- .../views/fp_job_step_quick_look_views.xml | 88 ++++- .../views/res_config_settings_views.xml | 21 -- .../wizards/fp_job_step_input_wizard.py | 4 +- .../fusion_plating_logistics/__manifest__.py | 2 +- .../models/fp_delivery.py | 54 +++ .../__manifest__.py | 2 +- .../models/__init__.py | 1 + .../models/account_move_send.py | 50 +++ .../fusion_plating_reports/__manifest__.py | 2 +- .../report/report_actions.xml | 1 + fusion_theme_switcher/__init__.py | 2 + fusion_theme_switcher/__manifest__.py | 33 ++ .../static/src/js/theme_toggle_systray.js | 81 ++++ .../static/src/scss/theme_toggle_systray.scss | 48 +++ .../static/src/xml/theme_toggle_systray.xml | 21 ++ 59 files changed, 2461 insertions(+), 324 deletions(-) create mode 100644 AGENTS.md create mode 100644 fusion_helpdesk/static/description/help_icon.png create mode 100644 fusion_plating/fusion_plating_configurator/migrations/19.0.18.11.0/post-migrate.py create mode 100644 fusion_plating/fusion_plating_configurator/wizard/fp_part_revision_bump_wizard.py create mode 100644 fusion_plating/fusion_plating_configurator/wizard/fp_part_revision_bump_wizard_views.xml delete mode 100644 fusion_plating/fusion_plating_jobs/models/res_config_settings.py create mode 100644 fusion_plating/fusion_plating_jobs/models/res_users.py delete mode 100644 fusion_plating/fusion_plating_jobs/views/res_config_settings_views.xml create mode 100644 fusion_plating/fusion_plating_notifications/models/account_move_send.py create mode 100644 fusion_theme_switcher/__init__.py create mode 100644 fusion_theme_switcher/__manifest__.py create mode 100644 fusion_theme_switcher/static/src/js/theme_toggle_systray.js create mode 100644 fusion_theme_switcher/static/src/scss/theme_toggle_systray.scss create mode 100644 fusion_theme_switcher/static/src/xml/theme_toggle_systray.xml diff --git a/.DS_Store b/.DS_Store index 64b305d774096a88ea487c263e3e6fb514c27fa6..2a46f2d739eeba2222a4fd65c7b108219809fd38 100644 GIT binary patch delta 434 zcmZoEXepTBFKW)fz`)GFAi%&-%#g{D&ydFu&yX|mqVi+|6BbrRpd`!W1YyIC2fg?i zl{fbZyK_x`Eh;d1p{(fSo05&3MkXdEItnJnmXr4hN>8qnQkgtaR(0}0DHByGX{mSt z;iSam?DV4i(!3Ps{G9wEr<~H%==7q@l;DEIg8j7p5!j3$h>jP{HkjGl~9jLD1{jG2r@leHz4K~Cc-3ogpb${yz>%4Aa5*u0GrPfGb^u}Bb*TUV delta 108 zcmZoEXepTBFKWWTz`)GFAi%&-%#g{D&ydHU%TPY?qVi+|6BbrRpd`!W^NLE7!!-Ce z9`xd8)ZLt<5yCvVMO$v-I^oUy0xn#e%Vf=%7}+-SC}?nOW|YujoV;I%ck^q}3>Kh_ L5z}UNgTL$muDcwx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..82abe38f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# Odoo Modules — Codex Instructions + +## Project +27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems). + +## Critical Rules — Odoo 19 +1. **NEVER code from memory** — Always read a reference file from Docker first: + ```bash + docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons//static/src/ + ``` +2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded. +3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`. +4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated). +5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields. +6. **res.groups**: NO `users` field, NO `category_id` field. +7. **Search views**: NO `group expand="0"` syntax. +8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file. + +## Card Styling — Copy Odoo's Kanban Pattern +Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values: +```css +background-color: white; +border: 1px solid #d8dadd; +``` +For custom OWL dashboards / client actions use the same approach: +- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property: + ```scss + $fp-card: var(--fp-card-bg, #ffffff); + $fp-border: var(--fp-border-color, #d8dadd); + ``` +- Reference those tokens everywhere (never `var(--bs-border-color)` directly) +- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop. +- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`. + +## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time +Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles: +- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright` +- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first) + +Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets: + +```scss +$o-webclient-color-scheme: bright !default; + +$_my-page-hex: #f3f4f6; +$_my-card-hex: #ffffff; + +@if $o-webclient-color-scheme == dark { + $_my-page-hex: #1a1d21 !global; + $_my-card-hex: #22262d !global; +} + +$my-page: var(--my-page-bg, $_my-page-hex); +$my-card: var(--my-card-bg, $_my-card-hex); +``` + +**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time. + +Verify by inspecting the attachments — you should see two files with different URLs for the two bundles: +```python +env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light +env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark +``` + +## Asset Bundle Cache Busting +Odoo content-hashes the compiled bundle URL (`/web/assets//...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation: +1. Bump the module `version` in `__manifest__.py` +2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo +3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration +4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data + +## Naming +- New fields: `x_fc_*` prefix +- Legacy fields: `x_studio_*` +- Canadian English for all user-facing text +- Currency: `$` sign with Monetary fields + currency_id + +## Cursor-Managed Modules +- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state + +## Workflow +- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u --stop-after-init` +- Local URL: http://localhost:8069 +- Test before deploying. Edit existing files — don't create unnecessary new ones. + +## Supabase Knowledge Base +Before starting unfamiliar work, check Supabase for context: +```bash +PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres +``` +- `fusionapps.decisions` — past architecture decisions +- `fusionapps.issues` — known issues and fixes +- `fusionapps.code_snippets` — reference code +- `fusionapps.quick_commands` — deployment and admin commands diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py index 8560f27c..11f38e4d 100644 --- a/fusion_helpdesk/__manifest__.py +++ b/fusion_helpdesk/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Helpdesk Reporter', - 'version': '19.0.1.2.0', + 'version': '19.0.1.3.0', 'category': 'Productivity', 'summary': 'One-click in-app bug reporting & feature requesting — ' 'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.', diff --git a/fusion_helpdesk/static/description/help_icon.png b/fusion_helpdesk/static/description/help_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f5cf3035fe10355551d12886de8121747cfcc6fe GIT binary patch literal 34844 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

*fdbpxXq; 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 z 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 literal 0 HcmV?d00001 diff --git a/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml b/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml index 550435bf..4aefb028 100644 --- a/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml +++ b/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml @@ -7,7 +7,7 @@ class="o_fhd_systray_btn dropdown-toggle" title="Report a bug or request a feature" t-on-click="onClick"> - Help diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 6452f559..b4b72ba4 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -108,6 +108,7 @@ These modules have **source code in this repo** but are **intentionally NOT inst 11. **XML data ordering**: Window actions must be defined BEFORE `` elements that reference them in the same file. 12. **Module install on new modules**: Use `--update=base` alongside `-i MODULE` to ensure Odoo rescans the addons path and finds the new module directory. 13. **Implied group cascade**: `implied_ids` on `res.groups` does NOT reliably propagate to users on module install. Always include `user_ids` to explicitly assign admin, or fix via SQL post-install. +14. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating. ## Naming - **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 113e254c..b640932d 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.18.13.8', + 'version': '19.0.18.13.13', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py index 3101bb04..1fe54f07 100644 --- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py +++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py @@ -37,6 +37,25 @@ _INPUT_SNAPSHOT_FIELDS = [ ] +def _copy_snapshot_fields(source, fields): + """Copy ``fields`` from ``source`` record into a write-ready dict. + + Many2one values must be unwrapped to their integer id — passing a + recordset to ``create`` triggers psycopg2 ``can't adapt type X`` + because the SQL adapter doesn't know how to serialize a recordset. + Scalar fields pass through untouched. + """ + out = {} + for f in fields: + field = source._fields[f] + val = source[f] + if field.type == 'many2one': + out[f] = val.id if val else False + else: + out[f] = val + return out + + class SimpleRecipeController(http.Controller): # ------------------------------------------------------------------ load @@ -115,6 +134,18 @@ class SimpleRecipeController(http.Controller): ), 'measurements_badge_text': badge_text, 'measurements_badge_class': badge_class, + # Reference images attached to the step. Operators see + # these in the Record Inputs dialog and the step quick-look + # modal — recipe authors upload via the inline edit panel. + 'instruction_images': [ + { + 'id': att.id, + 'name': att.name or '', + 'mimetype': att.mimetype or '', + 'url': '/web/image/%s' % att.id, + } + for att in step.instruction_attachment_ids + ], 'inputs': [ { 'id': i.id, @@ -457,8 +488,7 @@ class SimpleRecipeController(http.Controller): tpl = False if template_id: tpl = request.env['fp.step.template'].browse(template_id) - for f in _SNAPSHOT_FIELDS: - new_vals[f] = tpl[f] + new_vals.update(_copy_snapshot_fields(tpl, _SNAPSHOT_FIELDS)) if tpl.process_type_id: new_vals['process_type_id'] = tpl.process_type_id.id if tpl.tank_ids: @@ -598,8 +628,7 @@ class SimpleRecipeController(http.Controller): 'sequence': src_node.sequence, 'source_template_id': src_node.source_template_id.id or False, } - for f in _SNAPSHOT_FIELDS: - new_vals[f] = src_node[f] + new_vals.update(_copy_snapshot_fields(src_node, _SNAPSHOT_FIELDS)) if src_node.process_type_id: new_vals['process_type_id'] = src_node.process_type_id.id if src_node.tank_ids: @@ -690,6 +719,69 @@ class SimpleRecipeController(http.Controller): rec.unlink() return {'ok': True} + # ============================================================ + # Step instruction images — recipe authors attach reference photos + # / screenshots / diagrams to a step from the Simple Editor's inline + # edit panel. Operators see them on the Record Inputs dialog and + # the step quick-look modal at runtime. + # ============================================================ + + @http.route('/fp/simple_recipe/step/image/add', type='jsonrpc', auth='user') + def step_image_add(self, node_id, filename, datas, mimetype=None): + """Upload a new instruction image to a recipe step. + + Args: + node_id: recipe node (fusion.plating.process.node) id + filename: display name (with extension) for the attachment + datas: base64-encoded image payload (no data: URL prefix) + mimetype: optional override; falls back to image/png + + Returns the new attachment metadata so the JS can append it to + the step's gallery without a full reload. + """ + node = request.env['fusion.plating.process.node'].browse(int(node_id)) + node.check_access('write') + att = request.env['ir.attachment'].create({ + 'name': filename or 'image.png', + 'datas': datas, + 'res_model': 'fusion.plating.process.node', + 'res_id': node.id, + 'mimetype': mimetype or 'image/png', + }) + node.instruction_attachment_ids = [(4, att.id)] + return { + 'ok': True, + 'image': { + 'id': att.id, + 'name': att.name, + 'mimetype': att.mimetype or '', + 'url': '/web/image/%s' % att.id, + }, + } + + @http.route('/fp/simple_recipe/step/image/remove', type='jsonrpc', auth='user') + def step_image_remove(self, node_id, attachment_id): + """Unlink an instruction image from a recipe step. + + Soft-removes from the M2M; the underlying ir.attachment is + deleted only if it isn't referenced by any other recipe node. + """ + node = request.env['fusion.plating.process.node'].browse(int(node_id)) + node.check_access('write') + Att = request.env['ir.attachment'] + att = Att.browse(int(attachment_id)) + if not att.exists(): + return {'ok': False, 'error': 'not_found'} + node.instruction_attachment_ids = [(3, att.id)] + # Drop the attachment file too if no other node still links to it. + Node = request.env['fusion.plating.process.node'] + still_used = Node.search_count([ + ('instruction_attachment_ids', '=', att.id), + ]) + if not still_used: + att.sudo().unlink() + return {'ok': True} + @http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user') def step_reset_to_library(self, node_id): """Re-sync the recipe step's input_ids + description from the linked diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index 3204fdf4..be509735 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -129,19 +129,34 @@ class FpProcessNode(models.Model): ('fa-th', 'Grid / Racking'), ('fa-fire', 'Fire / Bake'), ('fa-bolt', 'Bolt / Electric'), + ('fa-flash', 'Flash / Discharge'), ('fa-diamond', 'Diamond / Plating'), ('fa-tint', 'Tint / Rinse'), ('fa-shower', 'Shower / Clean'), ('fa-bullseye', 'Target / Blast'), ('fa-search', 'Search / Inspect'), ('fa-check-circle', 'Check / Approve'), + ('fa-check-square-o', 'Checklist / QC'), ('fa-clock-o', 'Clock / Wait'), + ('fa-pause-circle', 'Pause / Hold'), ('fa-sun-o', 'Sun / Dry'), ('fa-thermometer-half', 'Temp / Heat'), + ('fa-cloud', 'Cloud / Atmosphere'), ('fa-eye', 'Eye / Visual'), + ('fa-eye-slash', 'Eye-Slash / Hidden'), ('fa-hand-paper-o', 'Hand / Manual'), ('fa-cube', 'Cube / Part'), ('fa-shield', 'Shield / Protect'), + ('fa-inbox', 'Inbox / Receiving'), + ('fa-archive', 'Archive / Storage'), + ('fa-truck', 'Truck / Ship'), + ('fa-paper-plane', 'Paper-Plane / Send'), + ('fa-link', 'Link / Chain'), + ('fa-scissors', 'Scissors / Cut'), + ('fa-server', 'Server / Stack'), + ('fa-tachometer', 'Tachometer / Gauge'), + ('fa-file-text-o', 'Document / Form'), + ('fa-plus-circle', 'Plus / Add'), ], string='Icon', default='fa-cog', @@ -151,6 +166,32 @@ class FpProcessNode(models.Model): default=0, ) + # ---- Reference images / instruction screenshots ------------------------- + # Recipe authors attach photos and screenshots here so operators see + # them on the shop floor when running the step. Anything from a + # process diagram, masking-line photo, or annotated screenshot of the + # WI document. Many2many — supports zero, one, or many images. + instruction_attachment_ids = fields.Many2many( + 'ir.attachment', + 'fp_node_instruction_attachment_rel', + 'node_id', 'attachment_id', + string='Instruction Images', + domain=[('mimetype', 'ilike', 'image/')], + help='Reference photos and screenshots that operators see at ' + 'runtime. Anything visual that helps them execute the step ' + 'correctly — fixture orientation, masking pattern, gauge ' + 'reading. Supports multiple images per step.', + ) + instruction_attachment_count = fields.Integer( + string='Instruction Image Count', + compute='_compute_instruction_attachment_count', + ) + + @api.depends('instruction_attachment_ids') + def _compute_instruction_attachment_count(self): + for rec in self: + rec.instruction_attachment_count = len(rec.instruction_attachment_ids) + # ---- Timing -------------------------------------------------------------- estimated_duration = fields.Float( @@ -722,11 +763,16 @@ class FpProcessNodeInput(models.Model): ) target_min = fields.Float( string='Target Min', - help='Lower bound of the acceptable range, expressed in Target Unit.', + digits=(16, 6), + help='Lower bound of the acceptable range, expressed in Target Unit. ' + 'Stored to 6 decimal places to support plating thicknesses ' + '(e.g. 0.000050 in / 50 micro-inches).', ) target_max = fields.Float( string='Target Max', - help='Upper bound of the acceptable range, expressed in Target Unit.', + digits=(16, 6), + help='Upper bound of the acceptable range, expressed in Target Unit. ' + 'Stored to 6 decimal places.', ) target_unit = fields.Selection( FP_UOM_SELECTION, diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js index b433c0eb..1e290f66 100644 --- a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js +++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js @@ -690,6 +690,86 @@ export class FpSimpleRecipeEditor extends Component { this._fpResetStepEdit(); } + // -------------------- Instruction images ------------------------------- + // + // Recipe authors drop reference photos / screenshots into this list + // while editing a step. Operators see the gallery at runtime in the + // Record Inputs dialog and the step quick-look modal. Backed by + // /fp/simple_recipe/step/image/{add,remove}; mirrors the upload + // affordance available on the tree-editor side. + + async onUploadStepImages(stepId, ev) { + const files = Array.from(ev.target.files || []); + if (!files.length) return; + for (const file of files) { + if (!file.type.startsWith("image/")) { + this.notification.add( + _t("%s isn't an image — skipped.").replace("%s", file.name), + { type: "warning" }, + ); + continue; + } + // Read as base64 (strip the "data:...;base64," prefix). + // eslint-disable-next-line no-await-in-loop + const datas = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result || ""; + resolve(String(result).split(",")[1] || ""); + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + // eslint-disable-next-line no-await-in-loop + const result = await rpc("/fp/simple_recipe/step/image/add", { + node_id: stepId, + filename: file.name, + datas: datas, + mimetype: file.type, + }); + if (!result.ok) { + this.notification.add( + _t("Could not upload %s.").replace("%s", file.name), + { type: "danger" }, + ); + continue; + } + // Append directly to the in-memory step so the gallery + // updates without re-loading the whole recipe tree. + const step = this.state.steps.find((s) => s.id === stepId); + if (step) { + step.instruction_images = [ + ...(step.instruction_images || []), + result.image, + ]; + } + } + // Clear the file input so the same file can be uploaded again + // after a remove + re-add cycle. + ev.target.value = ""; + this.notification.add(_t("Image(s) attached"), { type: "success" }); + } + + async onRemoveStepImage(stepId, attachmentId) { + const result = await rpc("/fp/simple_recipe/step/image/remove", { + node_id: stepId, + attachment_id: attachmentId, + }); + if (!result.ok) { + this.notification.add( + _t("Could not remove image."), + { type: "danger" }, + ); + return; + } + const step = this.state.steps.find((s) => s.id === stepId); + if (step) { + step.instruction_images = (step.instruction_images || []).filter( + (img) => img.id !== attachmentId, + ); + } + } + // -------------------- Sub 12d — measurements config -------------------- async onToggleStepCollect(stepId, collect) { diff --git a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss index c2bcc297..5c55947f 100644 --- a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss +++ b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss @@ -54,17 +54,61 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex}); .o_fp_simple_editor_meta { background: $fp-se-card; border: 1px solid $fp-se-border; - border-radius: 4px; - padding: 1rem; + border-radius: 6px; + padding: 1rem 1.25rem; margin-bottom: 1rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, .04); .o_fp_import_row { display: flex; align-items: center; gap: .75rem; - label { font-weight: 500; margin: 0; min-width: 14rem; } - select { flex: 1; max-width: 30rem; } + .o_fp_import_label { + margin: 0; + font-weight: 600; + color: $fp-se-accent; + white-space: nowrap; + display: inline-flex; + align-items: center; + + .fa { + color: $fp-se-accent; + opacity: .8; + } + } + + // Bootstrap's form-select gives us the chevron + base styling; + // we just tighten the colours to the card tokens so the field + // sits flush in our themed panel instead of fighting it. + .o_fp_import_select { + flex: 1; + max-width: 32rem; + min-height: 2.25rem; + background-color: $fp-se-card; + color: inherit; + border-color: $fp-se-border; + transition: border-color .15s ease, box-shadow .15s ease; + + &:hover:not(:focus):not(:disabled) { + border-color: $fp-se-accent; + } + + &:focus { + border-color: $fp-se-accent; + box-shadow: 0 0 0 .15rem rgba(46, 125, 107, .18); + outline: none; + } + } + + .o_fp_import_btn { + white-space: nowrap; + min-height: 2.25rem; + + .fa { + opacity: .9; + } + } } } @@ -492,3 +536,109 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex}); justify-content: flex-end; } + +// ============================================================================= +// Instruction images gallery — recipe-author upload + thumbnail strip in +// the Simple Editor's inline step edit panel. Mirrors what the Record +// Inputs dialog renders at runtime so authors can preview the same way +// the operator will see it. +// ============================================================================= + +.o_fp_step_images { + .o_fp_step_images_gallery { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin: .5rem 0; + } + + .o_fp_step_image_card { + position: relative; + width: 110px; + background: $fp-se-card; + border: 1px solid $fp-se-border; + border-radius: 6px; + overflow: hidden; + transition: border-color .12s ease, box-shadow .12s ease; + + &:hover { + border-color: $fp-se-accent; + box-shadow: 0 2px 6px rgba(0, 0, 0, .12); + } + + a { + display: block; + width: 100%; + height: 90px; + cursor: zoom-in; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .o_fp_step_image_remove { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + padding: 0; + border-radius: 50%; + background: rgba(0, 0, 0, .55); + color: #fff; + border: 0; + cursor: pointer; + opacity: 0; + transition: opacity .12s ease, background-color .12s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: .75rem; + } + + .o_fp_step_image_card:hover .o_fp_step_image_remove, + .o_fp_step_image_remove:focus { + opacity: 1; + } + + .o_fp_step_image_remove:hover { + background: #c0392b; + } + + .o_fp_step_image_caption { + font-size: .7rem; + padding: 4px 6px; + color: $fp-se-muted; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-top: 1px solid $fp-se-border; + } + + .o_fp_step_image_uploader { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + margin-top: .25rem; + background: $fp-se-card; + border: 1px dashed $fp-se-border; + border-radius: 6px; + cursor: pointer; + color: $fp-se-accent; + font-weight: 500; + transition: border-color .12s ease, background-color .12s ease; + + &:hover { + border-color: $fp-se-accent; + border-style: solid; + background: rgba(46, 125, 107, .06); + } + } +} + diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml index 4e0b4435..df2cebe2 100644 --- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml @@ -23,8 +23,13 @@

- - -
@@ -193,6 +200,56 @@
+ + +
+ +

+ Reference photos / screenshots / diagrams shown + to operators while running this step. Drop + multiple images for masking patterns, fixture + orientation, gauge readings, etc. +

+ +
- + + - - - - - - - - - + @@ -215,15 +201,48 @@ class="btn-link"/> + + + + + +

+ Seeds the treatment fields on new direct-order + lines for this part. Updated whenever "Save as + Default" is ticked while placing an order. +

- + + + + + + - + @@ -284,8 +303,7 @@ - - + @@ -295,20 +313,6 @@ - - - - - -

- Seeds the treatment fields on new direct-order - lines. Updated whenever "Save as Default" is - ticked while placing an order. -

-
diff --git a/fusion_plating/fusion_plating_configurator/views/res_partner_views.xml b/fusion_plating/fusion_plating_configurator/views/res_partner_views.xml index 149cc879..511b08b4 100644 --- a/fusion_plating/fusion_plating_configurator/views/res_partner_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/res_partner_views.xml @@ -43,7 +43,6 @@ - diff --git a/fusion_plating/fusion_plating_configurator/wizard/__init__.py b/fusion_plating/fusion_plating_configurator/wizard/__init__.py index 47287ce1..9e64a5c6 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/__init__.py +++ b/fusion_plating/fusion_plating_configurator/wizard/__init__.py @@ -8,4 +8,5 @@ from . import fp_add_from_so_wizard from . import fp_add_from_quote_wizard from . import fp_quote_promote_wizard from . import fp_part_catalog_import_wizard +from . import fp_part_revision_bump_wizard from . import fp_serial_bulk_add_wizard diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py index 29886bf2..b6ab17cd 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py @@ -55,7 +55,10 @@ class FpDirectOrderLine(models.Model): coating_config_id = fields.Many2one( 'fp.coating.config', string='Primary Treatment', - required=True, + help='Optional. Some orders are non-coating work (re-inspection, ' + 'rework, masking-only, etc.) and the operator picks the ' + 'workflow downstream — leaving this blank lets that path ' + 'through.', ) treatment_ids = fields.Many2many( 'fp.treatment', @@ -665,7 +668,7 @@ class FpDirectOrderLine(models.Model): new_rev = self.env['fp.part.catalog'].search([ ('parent_part_id', '=', (part.parent_part_id or part).id), ('is_latest_revision', '=', True), - ], limit=1, order='revision_number desc') + ], limit=1, order='revision_date desc') if not new_rev: return part diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py index 18fe027f..43edeb6d 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py @@ -189,21 +189,23 @@ class FpDirectOrderWizard(models.Model): rec.total_qty = sum(rec.line_ids.mapped('quantity')) rec.total_line_count = len(rec.line_ids) - @api.depends('line_ids.part_catalog_id', 'line_ids.coating_config_id', + @api.depends('line_ids.part_catalog_id', 'line_ids.unit_price', 'line_ids.quantity') def _compute_missing_info_msg(self): for rec in self: has_missing = False for line in rec.line_ids: + # coating_config_id intentionally NOT in the gate — + # it's optional now (rework / inspection-only / masking + # work doesn't need a primary treatment). if (not line.part_catalog_id - or not line.coating_config_id or not line.unit_price or not line.quantity): has_missing = True break rec.missing_info_msg = ( 'Some lines are missing quote information ' - '(part / treatment / price / qty). ' + '(part / price / qty). ' 'Verify before confirming the order.' if has_missing else False ) @@ -272,7 +274,10 @@ class FpDirectOrderWizard(models.Model): # Account-hold early warning. Hard block lives in action_confirm # but Sarah deserves to know NOW before she builds 5 lines. - if getattr(self.partner_id, 'x_fc_account_hold', False): + # Resolve via commercial_partner so a hold on the company is + # caught even when an Acme-AP child contact is selected. + commercial = self.partner_id.commercial_partner_id + if getattr(commercial, 'x_fc_account_hold', False): return { 'warning': { 'title': _('Customer on Account Hold'), @@ -280,7 +285,7 @@ class FpDirectOrderWizard(models.Model): '%s is currently on account hold. You can still ' 'build the quotation, but it cannot be confirmed ' 'until the hold is cleared by accounting.' - ) % self.partner_id.display_name, + ) % commercial.display_name, } } @@ -438,14 +443,24 @@ class FpDirectOrderWizard(models.Model): # Account-hold hard block — same policy as sale.order.action_confirm # but enforced earlier so the wizard doesn't waste Sarah's time. # Manager override allowed via context key fp_skip_account_hold=True. - if (getattr(self.partner_id, 'x_fc_account_hold', False) + # Resolved through commercial_partner so a hold on the company + # blocks every child-contact entry too. + commercial = self.partner_id.commercial_partner_id + # Bypass: Plating Manager OR Plating Administrator. Both checked + # because Odoo's implied_ids cascade (Administrator → Manager) + # doesn't always propagate to existing users on upgrade. See + # CLAUDE.md "Implied group cascade" rule. + can_override = ( + self.env.user.has_group('fusion_plating.group_fusion_plating_manager') + or self.env.user.has_group('fusion_plating.group_fusion_plating_administrator') + ) + if (getattr(commercial, 'x_fc_account_hold', False) and not self.env.context.get('fp_skip_account_hold') - and not self.env.user.has_group( - 'fusion_plating.group_fusion_plating_manager')): + and not can_override): raise UserError(_( 'Customer %s is on account hold. Have a manager clear the ' 'hold (or override) before creating the order.' - ) % self.partner_id.display_name) + ) % commercial.display_name) # Accept EITHER a PO (document + number) OR the PO Pending # flag. Customers who haven't sent paperwork yet use Pending; @@ -535,10 +550,14 @@ class FpDirectOrderWizard(models.Model): for line in self.line_ids: part = line._get_or_bump_revision() resolved_parts[line.id] = part + # Build the line header. Primary treatment is optional now; + # when missing, drop it from the header rather than printing + # "False - PartName Rev A". + treatment_label = line.coating_config_id.name or _('No coating') header = '%s - %s Rev %s (x%d)' % ( - line.coating_config_id.name, + treatment_label, part.name, - part.revision or part.revision_number, + part.revision, line.quantity, ) extended = (line.line_description or '').strip() diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml index 3f612c92..0483bf12 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml @@ -154,7 +154,8 @@ optional="hide"/> - + - + + options="{'currency_field': 'currency_id'}" + optional="show"/> + + + + + fp.part.revision.bump.wizard.form + fp.part.revision.bump.wizard + + + +
+

Create New Revision

+

+ Bump the revision label for + . + The pre-filled label is a best-effort guess — + adjust it to match the customer's actual scheme. +

+
+ + + + + + + + + + + + + + +
+ Added to the new revision's drawing list. + Leave empty to inherit the current drawings. +
+
+ + + +
+ Replaces the 3D model on the new revision. + Leave empty to inherit the current model. +
+
+
+
+
+
+ +
+
+ + + Create New Revision + fp.part.revision.bump.wizard + form + new + + +
diff --git a/fusion_plating/fusion_plating_invoicing/__manifest__.py b/fusion_plating/fusion_plating_invoicing/__manifest__.py index 6936c4ea..ce08eadd 100644 --- a/fusion_plating/fusion_plating_invoicing/__manifest__.py +++ b/fusion_plating/fusion_plating_invoicing/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Invoicing', - 'version': '19.0.3.3.0', + 'version': '19.0.3.5.0', 'category': 'Manufacturing/Plating', 'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.', 'description': """ diff --git a/fusion_plating/fusion_plating_invoicing/models/account_move.py b/fusion_plating/fusion_plating_invoicing/models/account_move.py index 8f645cf0..d5ac81a4 100644 --- a/fusion_plating/fusion_plating_invoicing/models/account_move.py +++ b/fusion_plating/fusion_plating_invoicing/models/account_move.py @@ -3,13 +3,22 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import api, models, _ +from odoo import api, fields, models, _ from odoo.exceptions import UserError class AccountMove(models.Model): _inherit = 'account.move' + # Mirrors the SO-side related field. See sale_order.py for the + # rationale (dotted refs in view modifiers are fragile + hold lives + # on the commercial partner). + x_fc_partner_account_hold = fields.Boolean( + string='Customer on Account Hold', + related='partner_id.commercial_partner_id.x_fc_account_hold', + store=True, readonly=True, + ) + @api.model_create_multi def create(self, vals_list): """Auto-inherit payment terms + customer PO# at creation time. @@ -55,17 +64,16 @@ class AccountMove(models.Model): """ for move in self: if move.move_type in ('out_invoice', 'out_refund') and move.partner_id: - if move.partner_id.x_fc_account_hold: - is_manager = self.env.user.has_group( - 'fusion_plating.group_fusion_plating_manager' - ) + hold_partner = move.partner_id.commercial_partner_id + if hold_partner.x_fc_account_hold: + is_manager = self.env['res.partner']._fp_user_can_override_account_hold() if not is_manager: raise UserError(_( 'Cannot post invoice — customer "%s" is on account hold.\n' 'Reason: %s\n\n' 'Contact a manager to override.' - ) % (move.partner_id.name, - move.partner_id.x_fc_account_hold_reason or 'No reason specified')) + ) % (hold_partner.name, + hold_partner.x_fc_account_hold_reason or 'No reason specified')) if not move.invoice_payment_term_id: raise UserError(_( 'Cannot post invoice "%s" — no payment terms set.\n\n' diff --git a/fusion_plating/fusion_plating_invoicing/models/res_partner.py b/fusion_plating/fusion_plating_invoicing/models/res_partner.py index 791734bf..8fe11c0e 100644 --- a/fusion_plating/fusion_plating_invoicing/models/res_partner.py +++ b/fusion_plating/fusion_plating_invoicing/models/res_partner.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import fields, models +from odoo import api, fields, models class ResPartner(models.Model): @@ -14,6 +14,25 @@ class ResPartner(models.Model): string='Account Hold', tracking=True, help='When active, blocks SO confirmation, invoicing, and shipping.', ) + + @api.model + def _fp_user_can_override_account_hold(self): + """True when the current user is allowed to override an account hold. + + Plating Manager OR Plating Administrator qualifies. Administrator + is checked explicitly (in addition to the implied chain) because + Odoo's ``implied_ids`` cascade does NOT reliably propagate to + existing users on module upgrade — admin (uid 1) typically lands + in Administrator only, with no Manager membership. Without this + defensive check, the highest-privileged user can't bypass holds. + + See CLAUDE.md "Implied group cascade" rule. + """ + user = self.env.user + return ( + user.has_group('fusion_plating.group_fusion_plating_manager') + or user.has_group('fusion_plating.group_fusion_plating_administrator') + ) x_fc_account_hold_reason = fields.Text(string='Hold Reason') x_fc_account_hold_date = fields.Datetime( string='Hold Date', help='When the hold was placed.', diff --git a/fusion_plating/fusion_plating_invoicing/models/sale_order.py b/fusion_plating/fusion_plating_invoicing/models/sale_order.py index 451e0009..02b29e97 100644 --- a/fusion_plating/fusion_plating_invoicing/models/sale_order.py +++ b/fusion_plating/fusion_plating_invoicing/models/sale_order.py @@ -15,6 +15,18 @@ _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _inherit = 'sale.order' + # Explicit related field — dotted refs like `partner_id.x_fc_account_hold` + # in `invisible=` modifiers are fragile in Odoo 19 (the related field + # has to be in the record cache for the evaluator). Surfacing it as a + # plain field on sale.order makes the banner condition deterministic. + # We resolve through `commercial_partner_id` so a hold placed on the + # company also blocks SOs entered against any of its child contacts. + x_fc_partner_account_hold = fields.Boolean( + string='Customer on Account Hold', + related='partner_id.commercial_partner_id.x_fc_account_hold', + store=True, readonly=True, + ) + @api.onchange('partner_id') def _onchange_partner_id_invoice_strategy(self): """Auto-fill plating defaults from customer profile. @@ -119,24 +131,27 @@ class SaleOrder(models.Model): ) % {'so': order.name}) # --- Account hold check --- - if order.partner_id.x_fc_account_hold: - is_manager = self.env.user.has_group( - 'fusion_plating.group_fusion_plating_manager' - ) + # Hold lives on the commercial_partner (the company). Resolve + # through that so a hold on the parent applies to every child + # contact too — typical case is "all of Acme is on hold", not + # "specifically the AP clerk's contact card". + hold_partner = order.partner_id.commercial_partner_id + if hold_partner.x_fc_account_hold: + is_manager = self.env['res.partner']._fp_user_can_override_account_hold() if not is_manager: raise UserError(_( 'Cannot confirm — customer "%s" is on account hold.\n' 'Reason: %s\n\n' 'Contact a manager to override.' - ) % (order.partner_id.name, - order.partner_id.x_fc_account_hold_reason or 'No reason specified')) + ) % (hold_partner.name, + hold_partner.x_fc_account_hold_reason or 'No reason specified')) else: order.message_post( body=_( 'Warning: Customer "%s" is on account hold (reason: %s). ' 'Order confirmed by manager override.' - ) % (order.partner_id.name, - order.partner_id.x_fc_account_hold_reason or 'N/A'), + ) % (hold_partner.name, + hold_partner.x_fc_account_hold_reason or 'N/A'), ) res = super().action_confirm() diff --git a/fusion_plating/fusion_plating_invoicing/views/sale_order_views.xml b/fusion_plating/fusion_plating_invoicing/views/sale_order_views.xml index d5819765..8a1ba050 100644 --- a/fusion_plating/fusion_plating_invoicing/views/sale_order_views.xml +++ b/fusion_plating/fusion_plating_invoicing/views/sale_order_views.xml @@ -13,10 +13,11 @@ + diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 54c6d89b..31d13c9b 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.18.4', + 'version': '19.0.8.20.6', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', @@ -20,10 +20,10 @@ 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. +As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only +fulfilment path. SO confirm always creates fp.job records here. The +former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0 +once the legacy fallback became unreachable. 19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel OWL/controller stack (job_process_tree, job_plant_overview, @@ -57,7 +57,6 @@ full design rationale and §6.2 of the implementation plan for task list. # 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/res_config_settings_views.xml', 'views/fp_job_step_quick_look_views.xml', 'views/fp_job_form_inherit.xml', 'views/fp_job_quality_buttons.xml', diff --git a/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py b/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py index 74772f9f..31e44ac7 100644 --- a/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py +++ b/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py @@ -57,6 +57,37 @@ class FpRecordInputsController(http.Controller): '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 + return { 'ok': True, 'step': { @@ -68,13 +99,16 @@ class FpRecordInputsController(http.Controller): 'name': step.job_id.name, }, '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): + def commit(self, step_id, values, advance_after=False, user_initials=None): """Commit operator-entered values for this step. Args: @@ -148,6 +182,17 @@ class FpRecordInputsController(http.Controller): 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, diff --git a/fusion_plating/fusion_plating_jobs/models/__init__.py b/fusion_plating/fusion_plating_jobs/models/__init__.py index b2734361..dd22473e 100644 --- a/fusion_plating/fusion_plating_jobs/models/__init__.py +++ b/fusion_plating/fusion_plating_jobs/models/__init__.py @@ -11,9 +11,9 @@ from . import fp_job_step from . import fp_job_node_override from . import fp_portal_job from . import account_move -from . import res_config_settings 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 diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 65f7bf9b..98804690 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -367,6 +367,16 @@ class FpJobStep(models.Model): if cr_action: return cr_action + # Racking step routing — same idea as Contract Review. If the + # operator clicks Finish on a Racking step but the linked + # racking inspection isn't done yet, route them straight to + # the inspection form instead of throwing a "find the smart + # button" error message. They complete the line check-off, + # mark Done, and re-click Finish & Next to advance. + ri_action = self._fp_racking_inspection_redirect() + if ri_action: + return ri_action + # Prompt-first behaviour: show the Record Inputs dialog when the # recipe step has authored prompts and nothing has been captured # in this run. Bypass when context flag is set (i.e. we're being @@ -631,15 +641,34 @@ class FpJobStep(models.Model): def _fp_open_contract_review(self): """Auto-create the QA-005 form for this step's part if missing, return the act_window pointing at it. Called from button_start - on Contract Review steps.""" + on Contract Review steps. + + Returns None when the review is already satisfied (state + 'complete' or 'dismissed') — letting button_start fall through + to the standard path so the step starts directly, without an + unnecessary detour through an already-signed form. This mirrors + the Finish & Next redirect behaviour: once contract review is + cleared for a part, neither Start nor Finish stops to ask + about it again. + + Also short-circuits when the customer doesn't require contract + review and via the manager-bypass context flag, to keep entry + and finish gates in lockstep. + """ self.ensure_one() + if self.env.context.get('fp_skip_contract_review_gate'): + return None part = self._fp_resolve_contract_review_part() if not part: return None + if not part.partner_id.x_fc_contract_review_required: + return None Review = self.env.get('fp.contract.review') if Review is None: return None # quality module not installed — skip review = part.x_fc_contract_review_id + if review and review.state in ('complete', 'dismissed'): + return None # already satisfied — fall through to normal start if not review: review = Review.sudo().create({ 'part_id': part.id, @@ -767,6 +796,46 @@ class FpJobStep(models.Model): 'name': _('Racking Inspection — %s') % self.job_id.name, } + def _fp_racking_inspection_redirect(self): + """Return an act_window opening the linked racking inspection + form, or False to indicate "no redirect needed". + + Mirrors ``_fp_contract_review_redirect``. Triggers when: + * this step is a Racking step (matched by ``_fp_is_racking_step``) + * the linked ``fp.racking.inspection`` exists and is NOT yet in + a terminal state (``done`` / ``discrepancy_flagged``) + + When the inspection is already terminal — or doesn't exist at + all — returns False so action_finish_and_advance falls through + to the normal finish path. The hard gate + (``_fp_check_racking_inspection_complete``) still fires from + ``button_finish`` for any caller that bypasses the redirect. + + Manager bypass via ``fp_skip_racking_inspection_gate=True``. + """ + self.ensure_one() + if self.env.context.get('fp_skip_racking_inspection_gate'): + return False + if not self._fp_is_racking_step(): + return False + if 'fp.racking.inspection' not in self.env: + return False + ri = self.job_id.racking_inspection_id + if not ri: + # No inspection record at all — let the soft gate handle + # this with a chatter warning, don't redirect. + return False + if ri.state in ('done', 'discrepancy_flagged'): + return False + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.racking.inspection', + 'res_id': ri.id, + 'view_mode': 'form', + 'target': 'current', + 'name': _('Racking Inspection — %s') % self.job_id.name, + } + def _fp_check_racking_inspection_complete(self): """Soft gate — block button_finish on a Racking step until the linked inspection is in a terminal state. discrepancy_flagged @@ -939,32 +1008,51 @@ class FpJobStep(models.Model): """Return an ir.actions.act_window opening the part's QA-005 Contract Review form, or False to indicate "no redirect needed". - Triggers when: - * the recipe node is flagged default_kind='contract_review', AND - * the linked part has no review yet OR the review is still in - a non-terminal state (draft / assistant_review / manager_review). + Triggers when ALL of these are true: + * the step is a Contract Review step (matched via + ``_fp_is_contract_review_step`` — name OR template kind OR + node kind, same as the finish-time gate), + * the customer requires contract review + (``partner.x_fc_contract_review_required = True``), AND + * the linked part either has no review yet OR the review is + still in a non-terminal state (draft / assistant_review / + manager_review). - Once the review reaches state 'complete' or 'dismissed' the step - is allowed to finish through the normal path, which is how the - operator clears the contract-review gate after signing QA-005. + Once the review reaches state 'complete' or 'dismissed' the + step is allowed to finish through the normal path. This is how + Finish & Next moves on to the next step automatically once the + contract review is already satisfied for that part — including + when the review was completed on a previous order. - Soft-fail: if the job has no part_catalog_id we cannot route to - a per-part review, so we fall through to the standard wizard - rather than blocking the operator. + Resolution mirrors ``_fp_check_contract_review_complete`` so a + single source of truth governs both ENTRY (this redirect) and + FINISH (the gate) — they always agree on whether a step is a + contract review and which part it's bound to. + + Soft-fail: if no part can be resolved we fall through to the + standard wizard rather than blocking the operator. """ self.ensure_one() - node = self.recipe_node_id - if not node or node.default_kind != 'contract_review': + # Manager bypass — same context flag the gate honours. + if self.env.context.get('fp_skip_contract_review_gate'): return False - part = self.job_id.part_catalog_id + if not self._fp_is_contract_review_step(): + return False + part = self._fp_resolve_contract_review_part() \ + or self.job_id.part_catalog_id if not part: _logger.warning( - "Contract-review step '%s' on job %s has no part_catalog_id " - "— cannot redirect to QA-005 form, falling through to " + "Contract-review step '%s' on job %s has no part — " + "cannot redirect to QA-005 form, falling through to " "standard wizard.", self.name, self.job_id.name, ) return False + # Customer flag check — when the customer doesn't require + # contract review, the redirect doesn't fire and the step + # finishes through the normal path. Matches the gate's policy. + if not part.partner_id.x_fc_contract_review_required: + return False review = part.x_fc_contract_review_id if review and review.state in ('complete', 'dismissed'): return False @@ -1022,6 +1110,28 @@ class FpJobStep(models.Model): related='recipe_node_id.collect_measurements', readonly=True, ) + # Job context related fields — used by the quick-look modal so the + # operator can see which job / customer / part / qty this step + # belongs to without opening the parent job form. Related (not + # stored) so they always reflect the live job record. + quick_look_partner_id = fields.Many2one( + 'res.partner', string='Customer', + related='job_id.partner_id', readonly=True, + ) + quick_look_part_catalog_id = fields.Many2one( + 'fp.part.catalog', string='Part', + related='job_id.part_catalog_id', readonly=True, + ) + quick_look_qty = fields.Float( + string='Order Qty', + related='job_id.qty', readonly=True, + ) + quick_look_instruction_attachment_ids = fields.Many2many( + 'ir.attachment', + string='Instruction Images', + related='recipe_node_id.instruction_attachment_ids', + readonly=True, + ) quick_look_prompt_ids = fields.Many2many( 'fusion.plating.process.node.input', string='Prompts', diff --git a/fusion_plating/fusion_plating_jobs/models/res_config_settings.py b/fusion_plating/fusion_plating_jobs/models/res_config_settings.py deleted file mode 100644 index df30d2c6..00000000 --- a/fusion_plating/fusion_plating_jobs/models/res_config_settings.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2026 Nexa Systems Inc. -# License OPL-1 (Odoo Proprietary License v1.0) -# -# x_fc_use_native_jobs — company-level setting that controls whether -# SO confirmation creates a native fp.job record (this module) or -# the legacy mrp.production / mrp.workorder records (bridge_mrp). -# -# Default: False (legacy MO flow). Phase 9 cutover flips this to True -# on entech. - -from odoo import fields, models - - -class ResConfigSettings(models.TransientModel): - _inherit = 'res.config.settings' - - x_fc_use_native_jobs = fields.Boolean( - string='Use Native Plating Jobs', - config_parameter='fusion_plating_jobs.use_native_jobs', - help='When enabled, SO confirmation creates fp.job records ' - 'instead of mrp.production. Phase-2 migration toggle.', - ) diff --git a/fusion_plating/fusion_plating_jobs/models/res_users.py b/fusion_plating/fusion_plating_jobs/models/res_users.py new file mode 100644 index 00000000..aefe9d4a --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/models/res_users.py @@ -0,0 +1,40 @@ +# -*- 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.', + ) + + @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() diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py index 79f7fb78..45be7cff 100644 --- a/fusion_plating/fusion_plating_jobs/models/sale_order.py +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -2,12 +2,10 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # -# sale.order.action_confirm hook — creates fp.job records when the -# x_fc_use_native_jobs setting is True. Mirrors bridge_mrp's -# _fp_auto_create_mo but creates fp.job instead of mrp.production. -# -# When the setting is False (default), this hook is a no-op and -# bridge_mrp's MO-creation hook handles the flow. +# 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 @@ -82,18 +80,7 @@ class SaleOrder(models.Model): ) def _compute_workflow_stage(self): - """Native-jobs override — walks fp.job state instead of mrp.production. - - When `use_native_jobs` is on, the SO is fulfilled by `fp.job` - records, not MRP MOs. The bridge_mrp compute reads `mrp.production` - and would falsely stall the banner. We branch at the top: native - mode → fp.job walker; legacy mode → super() (bridge_mrp). - """ - ICP = self.env['ir.config_parameter'].sudo() - native = ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True' - if not native: - return super()._compute_workflow_stage() - + """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: @@ -201,27 +188,24 @@ class SaleOrder(models.Model): def action_confirm(self): result = super().action_confirm() - # Only run when the native flag is on - ICP = self.env['ir.config_parameter'].sudo() - if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True': - 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}) + 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 _fp_auto_create_job(self): diff --git a/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js b/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js index 1b0281a3..9c9884b1 100644 --- a/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js +++ b/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js @@ -29,7 +29,31 @@ import { _t } from "@web/core/l10n/translation"; const NUMERIC_TYPES = new Set([ "number", "temperature", "thickness", "time_seconds", "ph", ]); -const BOOLEAN_TYPES = new Set(["boolean", "pass_fail"]); +// 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 { @@ -46,6 +70,18 @@ export class FpRecordInputsDialog extends Component { stepName: "", jobName: "", 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(); @@ -67,23 +103,86 @@ export class FpRecordInputsDialog extends Component { } this.state.stepName = data.step.name; this.state.jobName = data.job.name; - this.state.rows = data.prompts.map((p) => ({ - ...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: "", - })); + this.state.userInitials = data.user_initials || ""; + this.state.instructionsHtml = 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 + // 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); } @@ -92,12 +191,91 @@ export class FpRecordInputsDialog extends Component { 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"; } - // Fallback to text for anything else (text, signature, time_hms, ...) + isPassFail(row) { return row.input_type === "pass_fail"; } + isSignature(row) { return row.input_type === "signature"; } + // Fallback to text for anything else (text, time_hms, ...) 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.isSelection(row) && !this.isPassFail(row) + && !this.isSignature(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"; + } + + // 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 ------ @@ -125,7 +303,23 @@ export class FpRecordInputsDialog extends Component { return { kind: "ok", text: _t("in range") }; } - // ---- Photo upload — file → base64 ---------------------------------- + // 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; @@ -171,6 +365,7 @@ export class FpRecordInputsDialog extends Component { point_4: 0, point_5: 0, panel_ph: 0, panel_concentration: 0, panel_temperature: 0, panel_bath_id: "", + _passfail_chosen: "", }); } @@ -178,6 +373,21 @@ export class FpRecordInputsDialog extends Component { this.state.rows.splice(idx, 1); } + // 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 @@ -190,31 +400,74 @@ export class FpRecordInputsDialog extends Component { 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; + } + } this.state.saving = true; - const payload = this.state.rows.map((r) => ({ - 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: r.value_text || false, - value_number: r.value_number || 0, - value_boolean: r.value_boolean, - value_date: r.value_date || false, - 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 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) { @@ -229,9 +482,23 @@ export class FpRecordInputsDialog extends Component { { type: "success" }, ); this.props.close(); - // If commit returned an action (e.g. Finish & Advance), dispatch it - if (result.next_action && typeof result.next_action === "object") { - await this.action.doAction(result.next_action); + // 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", + }); } } diff --git a/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss b/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss index 99a44dde..4a94e82e 100644 --- a/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss +++ b/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss @@ -223,10 +223,42 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex}); // ---------- 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 { - margin: 0 0 8px 0; + 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-ink-mute; + 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; @@ -236,6 +268,69 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex}); } +// ============================================================================= +// 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 // ============================================================================= @@ -512,3 +607,197 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex}); 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; + } +} diff --git a/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml b/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml index f3566d49..ddcb5650 100644 --- a/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml +++ b/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml @@ -20,16 +20,39 @@ Loading prompts...
- -
+ +
+
+ +
+ + +

No measurement prompts on this step.

- -
+ +
@@ -53,7 +76,7 @@
+ t-esc="inputTypeLabel(row)"/> @@ -67,14 +90,19 @@
- -
+
- Target: - - + + Target + + - +
@@ -83,8 +111,9 @@
- -
+ +
- + +
+ + + + +
+ + + +
+ + + + +
+ +
+ + Readings suggest — confirm below. +
+
+ + +
+
+ + +
+ + +
+ +