diff --git a/.DS_Store b/.DS_Store index 64b305d7..2a46f2d7 100644 Binary files a/.DS_Store and b/.DS_Store differ 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 00000000..f5cf3035 Binary files /dev/null and b/fusion_helpdesk/static/description/help_icon.png differ 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. +
+
+ + +
+
+ + +
+ + +
+ +