changes
This commit is contained in:
94
AGENTS.md
Normal file
94
AGENTS.md
Normal file
@@ -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/<module>/static/src/<path>
|
||||||
|
```
|
||||||
|
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/<hash>/...`). 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 <module> --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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Reporter',
|
'name': 'Fusion Helpdesk Reporter',
|
||||||
'version': '19.0.1.2.0',
|
'version': '19.0.1.3.0',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'One-click in-app bug reporting & feature requesting — '
|
'summary': 'One-click in-app bug reporting & feature requesting — '
|
||||||
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
|
||||||
|
|||||||
BIN
fusion_helpdesk/static/description/help_icon.png
Normal file
BIN
fusion_helpdesk/static/description/help_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -7,7 +7,7 @@
|
|||||||
class="o_fhd_systray_btn dropdown-toggle"
|
class="o_fhd_systray_btn dropdown-toggle"
|
||||||
title="Report a bug or request a feature"
|
title="Report a bug or request a feature"
|
||||||
t-on-click="onClick">
|
t-on-click="onClick">
|
||||||
<img src="/fusion_helpdesk/static/description/icon.png"
|
<img src="/fusion_helpdesk/static/description/help_icon.png"
|
||||||
alt="Help"
|
alt="Help"
|
||||||
class="o_fhd_systray_img"/>
|
class="o_fhd_systray_img"/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 `<menuitem>` elements that reference them in the same file.
|
11. **XML data ordering**: Window actions must be defined BEFORE `<menuitem>` 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.
|
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.
|
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
|
## Naming
|
||||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.13.8',
|
'version': '19.0.18.13.13',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -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):
|
class SimpleRecipeController(http.Controller):
|
||||||
|
|
||||||
# ------------------------------------------------------------------ load
|
# ------------------------------------------------------------------ load
|
||||||
@@ -115,6 +134,18 @@ class SimpleRecipeController(http.Controller):
|
|||||||
),
|
),
|
||||||
'measurements_badge_text': badge_text,
|
'measurements_badge_text': badge_text,
|
||||||
'measurements_badge_class': badge_class,
|
'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': [
|
'inputs': [
|
||||||
{
|
{
|
||||||
'id': i.id,
|
'id': i.id,
|
||||||
@@ -457,8 +488,7 @@ class SimpleRecipeController(http.Controller):
|
|||||||
tpl = False
|
tpl = False
|
||||||
if template_id:
|
if template_id:
|
||||||
tpl = request.env['fp.step.template'].browse(template_id)
|
tpl = request.env['fp.step.template'].browse(template_id)
|
||||||
for f in _SNAPSHOT_FIELDS:
|
new_vals.update(_copy_snapshot_fields(tpl, _SNAPSHOT_FIELDS))
|
||||||
new_vals[f] = tpl[f]
|
|
||||||
if tpl.process_type_id:
|
if tpl.process_type_id:
|
||||||
new_vals['process_type_id'] = tpl.process_type_id.id
|
new_vals['process_type_id'] = tpl.process_type_id.id
|
||||||
if tpl.tank_ids:
|
if tpl.tank_ids:
|
||||||
@@ -598,8 +628,7 @@ class SimpleRecipeController(http.Controller):
|
|||||||
'sequence': src_node.sequence,
|
'sequence': src_node.sequence,
|
||||||
'source_template_id': src_node.source_template_id.id or False,
|
'source_template_id': src_node.source_template_id.id or False,
|
||||||
}
|
}
|
||||||
for f in _SNAPSHOT_FIELDS:
|
new_vals.update(_copy_snapshot_fields(src_node, _SNAPSHOT_FIELDS))
|
||||||
new_vals[f] = src_node[f]
|
|
||||||
if src_node.process_type_id:
|
if src_node.process_type_id:
|
||||||
new_vals['process_type_id'] = src_node.process_type_id.id
|
new_vals['process_type_id'] = src_node.process_type_id.id
|
||||||
if src_node.tank_ids:
|
if src_node.tank_ids:
|
||||||
@@ -690,6 +719,69 @@ class SimpleRecipeController(http.Controller):
|
|||||||
rec.unlink()
|
rec.unlink()
|
||||||
return {'ok': True}
|
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')
|
@http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user')
|
||||||
def step_reset_to_library(self, node_id):
|
def step_reset_to_library(self, node_id):
|
||||||
"""Re-sync the recipe step's input_ids + description from the linked
|
"""Re-sync the recipe step's input_ids + description from the linked
|
||||||
|
|||||||
@@ -129,19 +129,34 @@ class FpProcessNode(models.Model):
|
|||||||
('fa-th', 'Grid / Racking'),
|
('fa-th', 'Grid / Racking'),
|
||||||
('fa-fire', 'Fire / Bake'),
|
('fa-fire', 'Fire / Bake'),
|
||||||
('fa-bolt', 'Bolt / Electric'),
|
('fa-bolt', 'Bolt / Electric'),
|
||||||
|
('fa-flash', 'Flash / Discharge'),
|
||||||
('fa-diamond', 'Diamond / Plating'),
|
('fa-diamond', 'Diamond / Plating'),
|
||||||
('fa-tint', 'Tint / Rinse'),
|
('fa-tint', 'Tint / Rinse'),
|
||||||
('fa-shower', 'Shower / Clean'),
|
('fa-shower', 'Shower / Clean'),
|
||||||
('fa-bullseye', 'Target / Blast'),
|
('fa-bullseye', 'Target / Blast'),
|
||||||
('fa-search', 'Search / Inspect'),
|
('fa-search', 'Search / Inspect'),
|
||||||
('fa-check-circle', 'Check / Approve'),
|
('fa-check-circle', 'Check / Approve'),
|
||||||
|
('fa-check-square-o', 'Checklist / QC'),
|
||||||
('fa-clock-o', 'Clock / Wait'),
|
('fa-clock-o', 'Clock / Wait'),
|
||||||
|
('fa-pause-circle', 'Pause / Hold'),
|
||||||
('fa-sun-o', 'Sun / Dry'),
|
('fa-sun-o', 'Sun / Dry'),
|
||||||
('fa-thermometer-half', 'Temp / Heat'),
|
('fa-thermometer-half', 'Temp / Heat'),
|
||||||
|
('fa-cloud', 'Cloud / Atmosphere'),
|
||||||
('fa-eye', 'Eye / Visual'),
|
('fa-eye', 'Eye / Visual'),
|
||||||
|
('fa-eye-slash', 'Eye-Slash / Hidden'),
|
||||||
('fa-hand-paper-o', 'Hand / Manual'),
|
('fa-hand-paper-o', 'Hand / Manual'),
|
||||||
('fa-cube', 'Cube / Part'),
|
('fa-cube', 'Cube / Part'),
|
||||||
('fa-shield', 'Shield / Protect'),
|
('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',
|
string='Icon',
|
||||||
default='fa-cog',
|
default='fa-cog',
|
||||||
@@ -151,6 +166,32 @@ class FpProcessNode(models.Model):
|
|||||||
default=0,
|
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 --------------------------------------------------------------
|
# ---- Timing --------------------------------------------------------------
|
||||||
|
|
||||||
estimated_duration = fields.Float(
|
estimated_duration = fields.Float(
|
||||||
@@ -722,11 +763,16 @@ class FpProcessNodeInput(models.Model):
|
|||||||
)
|
)
|
||||||
target_min = fields.Float(
|
target_min = fields.Float(
|
||||||
string='Target Min',
|
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(
|
target_max = fields.Float(
|
||||||
string='Target Max',
|
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(
|
target_unit = fields.Selection(
|
||||||
FP_UOM_SELECTION,
|
FP_UOM_SELECTION,
|
||||||
|
|||||||
@@ -690,6 +690,86 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
this._fpResetStepEdit();
|
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 --------------------
|
// -------------------- Sub 12d — measurements config --------------------
|
||||||
|
|
||||||
async onToggleStepCollect(stepId, collect) {
|
async onToggleStepCollect(stepId, collect) {
|
||||||
|
|||||||
@@ -54,17 +54,61 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
|||||||
.o_fp_simple_editor_meta {
|
.o_fp_simple_editor_meta {
|
||||||
background: $fp-se-card;
|
background: $fp-se-card;
|
||||||
border: 1px solid $fp-se-border;
|
border: 1px solid $fp-se-border;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
padding: 1rem;
|
padding: 1rem 1.25rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||||
|
|
||||||
.o_fp_import_row {
|
.o_fp_import_row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
|
|
||||||
label { font-weight: 500; margin: 0; min-width: 14rem; }
|
.o_fp_import_label {
|
||||||
select { flex: 1; max-width: 30rem; }
|
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;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,13 @@
|
|||||||
|
|
||||||
<div class="o_fp_simple_editor_meta" t-if="state.recipe">
|
<div class="o_fp_simple_editor_meta" t-if="state.recipe">
|
||||||
<div class="o_fp_import_row">
|
<div class="o_fp_import_row">
|
||||||
<label>Import starter from template:</label>
|
<label class="o_fp_import_label" for="fp_import_template_select">
|
||||||
<select t-model="state.selectedTemplate">
|
<i class="fa fa-download me-2"/>
|
||||||
|
Import starter from template
|
||||||
|
</label>
|
||||||
|
<select id="fp_import_template_select"
|
||||||
|
class="form-select o_fp_import_select"
|
||||||
|
t-model="state.selectedTemplate">
|
||||||
<option value="">— Select template —</option>
|
<option value="">— Select template —</option>
|
||||||
<t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
|
<t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
|
||||||
<option t-att-value="tpl.id">
|
<option t-att-value="tpl.id">
|
||||||
@@ -32,8 +37,10 @@
|
|||||||
</option>
|
</option>
|
||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary" t-on-click="onImportTemplate"
|
<button class="btn btn-primary o_fp_import_btn"
|
||||||
|
t-on-click="onImportTemplate"
|
||||||
t-att-disabled="!state.selectedTemplate">
|
t-att-disabled="!state.selectedTemplate">
|
||||||
|
<i class="fa fa-plus me-1"/>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +200,56 @@
|
|||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Instruction images — recipe author drops
|
||||||
|
photos / screenshots / diagrams here.
|
||||||
|
Operators see the gallery at runtime in
|
||||||
|
the Record Inputs dialog and the step
|
||||||
|
quick-look modal. -->
|
||||||
|
<div class="o_fp_edit_field o_fp_step_images">
|
||||||
|
<label>
|
||||||
|
<i class="fa fa-camera me-1"/>
|
||||||
|
<strong>Instruction Images</strong>
|
||||||
|
</label>
|
||||||
|
<p class="o_fp_edit_hint">
|
||||||
|
Reference photos / screenshots / diagrams shown
|
||||||
|
to operators while running this step. Drop
|
||||||
|
multiple images for masking patterns, fixture
|
||||||
|
orientation, gauge readings, etc.
|
||||||
|
</p>
|
||||||
|
<div class="o_fp_step_images_gallery"
|
||||||
|
t-if="step.instruction_images and step.instruction_images.length">
|
||||||
|
<t t-foreach="step.instruction_images"
|
||||||
|
t-as="img" t-key="img.id">
|
||||||
|
<div class="o_fp_step_image_card">
|
||||||
|
<a t-att-href="img.url"
|
||||||
|
target="_blank"
|
||||||
|
t-att-title="img.name">
|
||||||
|
<img t-att-src="img.url"
|
||||||
|
t-att-alt="img.name"/>
|
||||||
|
</a>
|
||||||
|
<button type="button"
|
||||||
|
class="o_fp_step_image_remove"
|
||||||
|
title="Remove image"
|
||||||
|
t-on-click="() => this.onRemoveStepImage(step.id, img.id)">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
<div class="o_fp_step_image_caption"
|
||||||
|
t-esc="img.name"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<label class="o_fp_step_image_uploader">
|
||||||
|
<i class="fa fa-plus me-1"/>
|
||||||
|
Upload images
|
||||||
|
<input type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple="multiple"
|
||||||
|
hidden="hidden"
|
||||||
|
t-on-change="(ev) => this.onUploadStepImages(step.id, ev)"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sub 12d — Measurements config -->
|
<!-- Sub 12d — Measurements config -->
|
||||||
<div class="o_fp_edit_field o_fp_measurements_config">
|
<div class="o_fp_edit_field o_fp_measurements_config">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -136,6 +136,22 @@
|
|||||||
<page string="Description" name="description">
|
<page string="Description" name="description">
|
||||||
<field name="description" widget="html"/>
|
<field name="description" widget="html"/>
|
||||||
</page>
|
</page>
|
||||||
|
<page string="Instruction Images"
|
||||||
|
name="instruction_images">
|
||||||
|
<p class="text-muted">
|
||||||
|
Photos and screenshots operators see while
|
||||||
|
running this step — masking patterns,
|
||||||
|
fixture orientation, annotated diagrams,
|
||||||
|
gauge readings. Drop one or many images
|
||||||
|
here; they appear on the shop-floor
|
||||||
|
step-detail panel and the Record Inputs
|
||||||
|
dialog.
|
||||||
|
</p>
|
||||||
|
<field name="instruction_attachment_ids"
|
||||||
|
widget="many2many_binary"
|
||||||
|
options="{'accepted_file_extensions': 'image/*'}"
|
||||||
|
nolabel="1"/>
|
||||||
|
</page>
|
||||||
<page string="Operator Inputs" name="inputs">
|
<page string="Operator Inputs" name="inputs">
|
||||||
<group>
|
<group>
|
||||||
<field name="collect_measurements"
|
<field name="collect_measurements"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.18.8.0',
|
'version': '19.0.18.12.9',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -59,6 +59,7 @@ Provides:
|
|||||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||||
'wizard/fp_quote_promote_wizard_views.xml',
|
'wizard/fp_quote_promote_wizard_views.xml',
|
||||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||||
|
'wizard/fp_part_revision_bump_wizard_views.xml',
|
||||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||||
'views/fp_configurator_menu.xml',
|
'views/fp_configurator_menu.xml',
|
||||||
'data/fp_sale_description_template_data.xml',
|
'data/fp_sale_description_template_data.xml',
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
"""Drop the redundant ``revision_number`` Integer column on fp.part.catalog.
|
||||||
|
|
||||||
|
The model historically carried two revision fields:
|
||||||
|
* ``revision`` (Char, required) — the customer's actual revision label
|
||||||
|
* ``revision_number`` (Integer) — an internal counter
|
||||||
|
|
||||||
|
The Integer counter duplicated information already in ``revision`` and
|
||||||
|
got out of sync whenever the customer used a non-numeric scheme
|
||||||
|
(A/B/C, A1/A2, "ECO-2024-014" etc.). This migration drops the column.
|
||||||
|
``action_create_revision`` and the auto-rev path on 3D-model upload now
|
||||||
|
use ``_bump_revision_label`` which best-effort bumps the alphanumeric
|
||||||
|
label and lets the user adjust to the customer's actual scheme.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
cr.execute("""
|
||||||
|
ALTER TABLE fp_part_catalog DROP COLUMN IF EXISTS revision_number;
|
||||||
|
""")
|
||||||
@@ -3,11 +3,56 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_revision_label(label):
|
||||||
|
"""Best-effort next revision after ``label``.
|
||||||
|
|
||||||
|
Customers use varied revision schemes (A/B/C, A1/A2, "Rev 1"/"Rev 2",
|
||||||
|
custom strings). This helper handles the common ones; for unrecognised
|
||||||
|
formats it returns ``label + '*'`` so the user knows they need to
|
||||||
|
fix the label manually.
|
||||||
|
|
||||||
|
- 'A' → 'B' ... 'Y' → 'Z' → 'AA'
|
||||||
|
- 'a' → 'b' (case preserved on single letter)
|
||||||
|
- 'A1' → 'A2', 'B12' → 'B13', 'Rev 1' → 'Rev 2'
|
||||||
|
- 'AB' → 'AC' (last letter incremented)
|
||||||
|
- everything else → ``label + '*'``
|
||||||
|
"""
|
||||||
|
if not label:
|
||||||
|
return 'A'
|
||||||
|
label = label.strip()
|
||||||
|
|
||||||
|
# Trailing digits — "Rev 1" → "Rev 2", "A1" → "A2".
|
||||||
|
# Preserve zero-padding when the original was padded ("014" → "015").
|
||||||
|
m = re.match(r'^(.*?)(\d+)$', label)
|
||||||
|
if m:
|
||||||
|
prefix, digits = m.group(1), m.group(2)
|
||||||
|
bumped = int(digits) + 1
|
||||||
|
if digits.startswith('0') and len(str(bumped)) <= len(digits):
|
||||||
|
return f"{prefix}{str(bumped).zfill(len(digits))}"
|
||||||
|
return f"{prefix}{bumped}"
|
||||||
|
|
||||||
|
# Single letter
|
||||||
|
if len(label) == 1 and label.isalpha():
|
||||||
|
if label.upper() == 'Z':
|
||||||
|
return 'AA' if label.isupper() else 'aa'
|
||||||
|
return chr(ord(label) + 1)
|
||||||
|
|
||||||
|
# Multi-char ending in letter — "AB" → "AC"
|
||||||
|
m = re.match(r'^(.*?)([A-Za-z])$', label)
|
||||||
|
if m and m.group(2).upper() != 'Z':
|
||||||
|
return m.group(1) + chr(ord(m.group(2)) + 1)
|
||||||
|
|
||||||
|
# Unknown format — caller must edit
|
||||||
|
return label + '*'
|
||||||
|
|
||||||
|
|
||||||
class FpPartCatalog(models.Model):
|
class FpPartCatalog(models.Model):
|
||||||
"""Customer part library.
|
"""Customer part library.
|
||||||
|
|
||||||
@@ -36,8 +81,12 @@ class FpPartCatalog(models.Model):
|
|||||||
tracking=True, domain="[('customer_rank', '>', 0)]",
|
tracking=True, domain="[('customer_rank', '>', 0)]",
|
||||||
)
|
)
|
||||||
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
|
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
|
||||||
revision = fields.Char(string='Revision', required=True, default='A', help='Revision letter or number (e.g. Rev: 1B).')
|
revision = fields.Char(
|
||||||
revision_number = fields.Integer(string='Rev #', default=1)
|
string='Revision', required=True, default='A',
|
||||||
|
help="Customer's drawing revision label. Free-text — accepts any "
|
||||||
|
"format the customer uses (A, B, C / A1, B2 / Rev 1, Rev 2 / "
|
||||||
|
"ECO-2024-014 etc.).",
|
||||||
|
)
|
||||||
revision_note = fields.Char(string='Revision Note', help='What changed in this revision.')
|
revision_note = fields.Char(string='Revision Note', help='What changed in this revision.')
|
||||||
revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now)
|
revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now)
|
||||||
parent_part_id = fields.Many2one(
|
parent_part_id = fields.Many2one(
|
||||||
@@ -643,21 +692,46 @@ class FpPartCatalog(models.Model):
|
|||||||
'target': 'current',
|
'target': 'current',
|
||||||
}
|
}
|
||||||
|
|
||||||
def action_create_revision(self):
|
def action_open_revision_wizard(self):
|
||||||
"""Create a new revision of this part. Copies all data, increments revision number."""
|
"""Open the interactive Create-New-Revision wizard.
|
||||||
|
|
||||||
|
This is what the form-header button calls. The wizard asks
|
||||||
|
the user for the revision label, note, and optionally a new
|
||||||
|
drawing/3D file BEFORE the new record is created — which is
|
||||||
|
what most users want.
|
||||||
|
|
||||||
|
For non-interactive callers (auto-rev on 3D upload, direct
|
||||||
|
order line bump) use ``action_create_revision`` directly.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Create New Revision'),
|
||||||
|
'res_model': 'fp.part.revision.bump.wizard',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {
|
||||||
|
'default_part_id': self.id,
|
||||||
|
'active_id': self.id,
|
||||||
|
'active_model': 'fp.part.catalog',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_create_revision(self):
|
||||||
|
"""Programmatic, non-interactive revision bump.
|
||||||
|
|
||||||
|
Copies the part with a best-effort label bump via
|
||||||
|
``_bump_revision_label``. Used by code paths that don't have
|
||||||
|
a user prompt (auto-rev when a new 3D model is uploaded on a
|
||||||
|
quote, direct-order line bump). User-facing flows should call
|
||||||
|
``action_open_revision_wizard`` instead.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
# Mark current as no longer latest
|
|
||||||
self.is_latest_revision = False
|
self.is_latest_revision = False
|
||||||
# Determine the root part for the chain
|
|
||||||
root = self.parent_part_id or self
|
root = self.parent_part_id or self
|
||||||
# Find highest revision number in chain
|
new_label = _bump_revision_label(self.revision)
|
||||||
all_revs = self.env['fp.part.catalog'].search([
|
|
||||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
|
||||||
])
|
|
||||||
max_rev = max(all_revs.mapped('revision_number') or [0])
|
|
||||||
new_rev = self.copy({
|
new_rev = self.copy({
|
||||||
'revision_number': max_rev + 1,
|
'revision': new_label,
|
||||||
'revision': f'Rev {max_rev + 1}',
|
|
||||||
'revision_date': fields.Datetime.now(),
|
'revision_date': fields.Datetime.now(),
|
||||||
'revision_note': False,
|
'revision_note': False,
|
||||||
'parent_part_id': root.id,
|
'parent_part_id': root.id,
|
||||||
|
|||||||
@@ -697,13 +697,10 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
old_part = self.part_catalog_id
|
old_part = self.part_catalog_id
|
||||||
old_part.is_latest_revision = False
|
old_part.is_latest_revision = False
|
||||||
root = old_part.parent_part_id or old_part
|
root = old_part.parent_part_id or old_part
|
||||||
all_revs = self.env['fp.part.catalog'].search([
|
from .fp_part_catalog import _bump_revision_label
|
||||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
new_label = _bump_revision_label(old_part.revision)
|
||||||
])
|
|
||||||
max_rev = max(all_revs.mapped('revision_number') or [0])
|
|
||||||
new_part = old_part.copy({
|
new_part = old_part.copy({
|
||||||
'revision_number': max_rev + 1,
|
'revision': new_label,
|
||||||
'revision': f'Rev {max_rev + 1}',
|
|
||||||
'revision_date': fields.Datetime.now(),
|
'revision_date': fields.Datetime.now(),
|
||||||
'revision_note': f'Updated 3D model: {fname}',
|
'revision_note': f'Updated 3D model: {fname}',
|
||||||
'parent_part_id': root.id,
|
'parent_part_id': root.id,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_co
|
|||||||
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
|
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
||||||
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -41,10 +41,22 @@ $fp-composer-muted: var(--fp-composer-muted, $_fp-composer-muted-hex);
|
|||||||
|
|
||||||
.o_fp_part_composer {
|
.o_fp_part_composer {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
max-width: 900px;
|
max-width: 1500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: $fp-composer-text;
|
color: $fp-composer-text;
|
||||||
|
|
||||||
|
// Variants table — keep the 5 action buttons (Tree / Simple /
|
||||||
|
// Duplicate / Rename / Delete) on a single row. Without this the
|
||||||
|
// Delete button wraps even on wide screens because Bootstrap's
|
||||||
|
// `.table` lets cells shrink to content+wrap.
|
||||||
|
.o_fp_part_composer_variants {
|
||||||
|
td:last-child,
|
||||||
|
th:last-child {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1%; // shrink-to-fit so buttons stay tight on the right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&_state {
|
&_state {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Part Catalog">
|
<form string="Part Catalog">
|
||||||
<header>
|
<header>
|
||||||
<button name="action_create_revision"
|
<button name="action_open_revision_wizard"
|
||||||
string="Create New Revision"
|
string="Create New Revision"
|
||||||
type="object"
|
type="object"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -110,40 +110,26 @@
|
|||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<label for="part_number" string="Part Number"/>
|
<label for="part_number" string="Part Number"/>
|
||||||
<h1><field name="part_number" placeholder="e.g. VS-R392007E01"/></h1>
|
<h1><field name="part_number" placeholder="e.g. VS-R392007E01"/></h1>
|
||||||
<field name="name" placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
|
|
||||||
</div>
|
</div>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group string="Identity">
|
||||||
|
<field name="name" string="Part Name"
|
||||||
|
placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
<field name="revision_number"/>
|
|
||||||
<field name="material_id"
|
|
||||||
options="{'no_quick_create': True}"/>
|
|
||||||
<field name="substrate_material" invisible="1"/>
|
|
||||||
<field name="geometry_source"/>
|
|
||||||
<field name="is_latest_revision" invisible="1"/>
|
<field name="is_latest_revision" invisible="1"/>
|
||||||
<field name="parent_part_id" invisible="not parent_part_id"/>
|
<field name="parent_part_id" invisible="not parent_part_id"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group string="Manufacturing Defaults">
|
||||||
<label for="surface_area"/>
|
<field name="material_id"
|
||||||
<div class="d-flex align-items-center gap-2">
|
options="{'no_quick_create': True}"/>
|
||||||
<field name="surface_area" class="oe_inline"/>
|
<field name="substrate_material" invisible="1"/>
|
||||||
<button name="action_calculate_surface_area" type="object"
|
|
||||||
string="Calculate from 3D Model"
|
|
||||||
class="btn-link" icon="fa-calculator"
|
|
||||||
invisible="not model_attachment_id"/>
|
|
||||||
</div>
|
|
||||||
<field name="surface_area_uom"/>
|
|
||||||
<field name="masking_area_sqin"/>
|
|
||||||
<field name="effective_area_sqin" readonly="1"/>
|
|
||||||
<field name="weight"/>
|
|
||||||
<field name="material_weight_kg" readonly="1"/>
|
|
||||||
<field name="x_fc_default_lead_time_days"/>
|
<field name="x_fc_default_lead_time_days"/>
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<group string="Quality & Delivery" name="quality_delivery">
|
|
||||||
<field name="certificate_requirement"/>
|
<field name="certificate_requirement"/>
|
||||||
</group>
|
</group>
|
||||||
|
</group>
|
||||||
|
<!-- Quality & Delivery moved into its own notebook tab below
|
||||||
|
(was a top-level group above the notebook). -->
|
||||||
<!-- Auto-extracted geometry from 3D model -->
|
<!-- Auto-extracted geometry from 3D model -->
|
||||||
<group string="3D Model Analysis"
|
<group string="3D Model Analysis"
|
||||||
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
|
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
|
||||||
@@ -215,15 +201,48 @@
|
|||||||
class="btn-link"/>
|
class="btn-link"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
|
<separator string="Default Treatments" class="mt-4"/>
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_default_coating_config_id"
|
||||||
|
string="Default Treatment"
|
||||||
|
options="{'no_create_edit': True}"/>
|
||||||
|
<field name="x_fc_default_treatment_ids"
|
||||||
|
string="Default Additional Treatments"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create_edit': True}"/>
|
||||||
|
</group>
|
||||||
|
<p class="text-muted">
|
||||||
|
Seeds the treatment fields on new direct-order
|
||||||
|
lines for this part. Updated whenever "Save as
|
||||||
|
Default" is ticked while placing an order.
|
||||||
|
</p>
|
||||||
</page>
|
</page>
|
||||||
<page string="Dimensions & Complexity" name="dimensions">
|
<page string="Dimensions & Complexity" name="dimensions">
|
||||||
<group>
|
<group>
|
||||||
|
<field name="geometry_source"/>
|
||||||
|
</group>
|
||||||
<group>
|
<group>
|
||||||
|
<group string="Surface & Weight">
|
||||||
|
<label for="surface_area"/>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<field name="surface_area" class="oe_inline"/>
|
||||||
|
<button name="action_calculate_surface_area" type="object"
|
||||||
|
string="Calculate from 3D Model"
|
||||||
|
class="btn-link" icon="fa-calculator"
|
||||||
|
invisible="not model_attachment_id"/>
|
||||||
|
</div>
|
||||||
|
<field name="surface_area_uom"/>
|
||||||
|
<field name="masking_area_sqin"/>
|
||||||
|
<field name="effective_area_sqin" readonly="1"/>
|
||||||
|
<field name="weight"/>
|
||||||
|
<field name="material_weight_kg" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Bounding Box">
|
||||||
<field name="dimensions_length"/>
|
<field name="dimensions_length"/>
|
||||||
<field name="dimensions_width"/>
|
<field name="dimensions_width"/>
|
||||||
<field name="dimensions_height"/>
|
<field name="dimensions_height"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group string="Complexity">
|
||||||
<field name="complexity"/>
|
<field name="complexity"/>
|
||||||
<field name="masking_zones"/>
|
<field name="masking_zones"/>
|
||||||
<field name="has_blind_holes"/>
|
<field name="has_blind_holes"/>
|
||||||
@@ -284,8 +303,7 @@
|
|||||||
<page string="Revision History" name="revisions"
|
<page string="Revision History" name="revisions"
|
||||||
invisible="not parent_part_id and not revision_ids">
|
invisible="not parent_part_id and not revision_ids">
|
||||||
<field name="revision_ids" mode="list">
|
<field name="revision_ids" mode="list">
|
||||||
<list default_order="revision_number desc">
|
<list default_order="revision_date desc">
|
||||||
<field name="revision_number" string="Rev #"/>
|
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
<field name="revision_note"/>
|
<field name="revision_note"/>
|
||||||
<field name="revision_date"/>
|
<field name="revision_date"/>
|
||||||
@@ -295,20 +313,6 @@
|
|||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
<page string="Defaults" name="direct_order_defaults">
|
|
||||||
<group>
|
|
||||||
<field name="x_fc_default_coating_config_id"
|
|
||||||
options="{'no_create_edit': True}"/>
|
|
||||||
<field name="x_fc_default_treatment_ids"
|
|
||||||
widget="many2many_tags"
|
|
||||||
options="{'no_create_edit': True}"/>
|
|
||||||
</group>
|
|
||||||
<p class="text-muted">
|
|
||||||
Seeds the treatment fields on new direct-order
|
|
||||||
lines. Updated whenever "Save as Default" is
|
|
||||||
ticked while placing an order.
|
|
||||||
</p>
|
|
||||||
</page>
|
|
||||||
<page string="Notes" name="notes">
|
<page string="Notes" name="notes">
|
||||||
<field name="notes" placeholder="Additional notes about this part..."/>
|
<field name="notes" placeholder="Additional notes about this part..."/>
|
||||||
</page>
|
</page>
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
<field name="part_number"/>
|
<field name="part_number"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
<field name="revision_number" string="Rev #"/>
|
|
||||||
<field name="substrate_material"/>
|
<field name="substrate_material"/>
|
||||||
<field name="surface_area"/>
|
<field name="surface_area"/>
|
||||||
<field name="surface_area_uom" string="UoM"/>
|
<field name="surface_area_uom" string="UoM"/>
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ from . import fp_add_from_so_wizard
|
|||||||
from . import fp_add_from_quote_wizard
|
from . import fp_add_from_quote_wizard
|
||||||
from . import fp_quote_promote_wizard
|
from . import fp_quote_promote_wizard
|
||||||
from . import fp_part_catalog_import_wizard
|
from . import fp_part_catalog_import_wizard
|
||||||
|
from . import fp_part_revision_bump_wizard
|
||||||
from . import fp_serial_bulk_add_wizard
|
from . import fp_serial_bulk_add_wizard
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ class FpDirectOrderLine(models.Model):
|
|||||||
coating_config_id = fields.Many2one(
|
coating_config_id = fields.Many2one(
|
||||||
'fp.coating.config',
|
'fp.coating.config',
|
||||||
string='Primary Treatment',
|
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(
|
treatment_ids = fields.Many2many(
|
||||||
'fp.treatment',
|
'fp.treatment',
|
||||||
@@ -665,7 +668,7 @@ class FpDirectOrderLine(models.Model):
|
|||||||
new_rev = self.env['fp.part.catalog'].search([
|
new_rev = self.env['fp.part.catalog'].search([
|
||||||
('parent_part_id', '=', (part.parent_part_id or part).id),
|
('parent_part_id', '=', (part.parent_part_id or part).id),
|
||||||
('is_latest_revision', '=', True),
|
('is_latest_revision', '=', True),
|
||||||
], limit=1, order='revision_number desc')
|
], limit=1, order='revision_date desc')
|
||||||
if not new_rev:
|
if not new_rev:
|
||||||
return part
|
return part
|
||||||
|
|
||||||
|
|||||||
@@ -189,21 +189,23 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
rec.total_qty = sum(rec.line_ids.mapped('quantity'))
|
rec.total_qty = sum(rec.line_ids.mapped('quantity'))
|
||||||
rec.total_line_count = len(rec.line_ids)
|
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')
|
'line_ids.unit_price', 'line_ids.quantity')
|
||||||
def _compute_missing_info_msg(self):
|
def _compute_missing_info_msg(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
has_missing = False
|
has_missing = False
|
||||||
for line in rec.line_ids:
|
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
|
if (not line.part_catalog_id
|
||||||
or not line.coating_config_id
|
|
||||||
or not line.unit_price
|
or not line.unit_price
|
||||||
or not line.quantity):
|
or not line.quantity):
|
||||||
has_missing = True
|
has_missing = True
|
||||||
break
|
break
|
||||||
rec.missing_info_msg = (
|
rec.missing_info_msg = (
|
||||||
'Some lines are missing quote information '
|
'Some lines are missing quote information '
|
||||||
'(part / treatment / price / qty). '
|
'(part / price / qty). '
|
||||||
'Verify before confirming the order.'
|
'Verify before confirming the order.'
|
||||||
if has_missing else False
|
if has_missing else False
|
||||||
)
|
)
|
||||||
@@ -272,7 +274,10 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
|
|
||||||
# Account-hold early warning. Hard block lives in action_confirm
|
# Account-hold early warning. Hard block lives in action_confirm
|
||||||
# but Sarah deserves to know NOW before she builds 5 lines.
|
# 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 {
|
return {
|
||||||
'warning': {
|
'warning': {
|
||||||
'title': _('Customer on Account Hold'),
|
'title': _('Customer on Account Hold'),
|
||||||
@@ -280,7 +285,7 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'%s is currently on account hold. You can still '
|
'%s is currently on account hold. You can still '
|
||||||
'build the quotation, but it cannot be confirmed '
|
'build the quotation, but it cannot be confirmed '
|
||||||
'until the hold is cleared by accounting.'
|
'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
|
# Account-hold hard block — same policy as sale.order.action_confirm
|
||||||
# but enforced earlier so the wizard doesn't waste Sarah's time.
|
# but enforced earlier so the wizard doesn't waste Sarah's time.
|
||||||
# Manager override allowed via context key fp_skip_account_hold=True.
|
# 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.context.get('fp_skip_account_hold')
|
||||||
and not self.env.user.has_group(
|
and not can_override):
|
||||||
'fusion_plating.group_fusion_plating_manager')):
|
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Customer %s is on account hold. Have a manager clear the '
|
'Customer %s is on account hold. Have a manager clear the '
|
||||||
'hold (or override) before creating the order.'
|
'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
|
# Accept EITHER a PO (document + number) OR the PO Pending
|
||||||
# flag. Customers who haven't sent paperwork yet use 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:
|
for line in self.line_ids:
|
||||||
part = line._get_or_bump_revision()
|
part = line._get_or_bump_revision()
|
||||||
resolved_parts[line.id] = part
|
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)' % (
|
header = '%s - %s Rev %s (x%d)' % (
|
||||||
line.coating_config_id.name,
|
treatment_label,
|
||||||
part.name,
|
part.name,
|
||||||
part.revision or part.revision_number,
|
part.revision,
|
||||||
line.quantity,
|
line.quantity,
|
||||||
)
|
)
|
||||||
extended = (line.line_description or '').strip()
|
extended = (line.line_description or '').strip()
|
||||||
|
|||||||
@@ -154,7 +154,8 @@
|
|||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="internal_description"
|
<field name="internal_description"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="coating_config_id"/>
|
<field name="coating_config_id"
|
||||||
|
optional="show"/>
|
||||||
<field name="process_variant_id"
|
<field name="process_variant_id"
|
||||||
string="Process / Recipe"
|
string="Process / Recipe"
|
||||||
options="{'no_quick_create': True}"
|
options="{'no_quick_create': True}"
|
||||||
@@ -196,10 +197,12 @@
|
|||||||
<field name="treatment_ids"
|
<field name="treatment_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="quantity"/>
|
<field name="quantity"
|
||||||
|
optional="show"/>
|
||||||
<field name="unit_price"
|
<field name="unit_price"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"
|
||||||
|
optional="show"/>
|
||||||
<field name="tax_ids"
|
<field name="tax_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
options="{'no_create': True}"
|
options="{'no_create': True}"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ CSV_COLUMNS = [
|
|||||||
'name', # required
|
'name', # required
|
||||||
'customer', # required unless wizard.partner_id set
|
'customer', # required unless wizard.partner_id set
|
||||||
'revision',
|
'revision',
|
||||||
'revision_number',
|
|
||||||
'substrate_material',
|
'substrate_material',
|
||||||
'surface_area',
|
'surface_area',
|
||||||
'surface_area_uom',
|
'surface_area_uom',
|
||||||
@@ -266,7 +265,6 @@ class FpPartCatalogImportWizard(models.TransientModel):
|
|||||||
'part_number': part_number,
|
'part_number': part_number,
|
||||||
'name': name,
|
'name': name,
|
||||||
'revision': (row.get('revision') or '').strip() or False,
|
'revision': (row.get('revision') or '').strip() or False,
|
||||||
'revision_number': _to_int(row.get('revision_number'), 1),
|
|
||||||
'substrate_material': substrate,
|
'substrate_material': substrate,
|
||||||
'surface_area': _to_float(row.get('surface_area')),
|
'surface_area': _to_float(row.get('surface_area')),
|
||||||
'surface_area_uom': uom,
|
'surface_area_uom': uom,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# -*- 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, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
from ..models.fp_part_catalog import _bump_revision_label
|
||||||
|
|
||||||
|
|
||||||
|
class FpPartRevisionBumpWizard(models.TransientModel):
|
||||||
|
"""Interactive wizard for creating a new revision of a part.
|
||||||
|
|
||||||
|
Replaces the old "click Create New Revision and immediately get a
|
||||||
|
half-blank form" UX. Now the user supplies the revision label,
|
||||||
|
revision note, and optionally a new drawing file BEFORE the
|
||||||
|
revision is created. The wizard pre-fills a best-effort label
|
||||||
|
via ``_bump_revision_label`` so the common case (A → B, A1 → A2,
|
||||||
|
Rev 1 → Rev 2, ECO-2024-014 → ECO-2024-015) is one click.
|
||||||
|
"""
|
||||||
|
_name = 'fp.part.revision.bump.wizard'
|
||||||
|
_description = 'Create Part Revision Wizard'
|
||||||
|
|
||||||
|
part_id = fields.Many2one(
|
||||||
|
'fp.part.catalog', string='Source Part', required=True,
|
||||||
|
readonly=True, ondelete='cascade',
|
||||||
|
)
|
||||||
|
current_revision = fields.Char(
|
||||||
|
string='Current Revision', related='part_id.revision', readonly=True,
|
||||||
|
)
|
||||||
|
new_revision = fields.Char(
|
||||||
|
string='New Revision', required=True,
|
||||||
|
help="The revision label for the new copy. Pre-filled with a "
|
||||||
|
"best-effort guess; edit to match the customer's actual "
|
||||||
|
"revision scheme (A/B/C, A1/A2, Rev 2, ECO-2024-015, etc.).",
|
||||||
|
)
|
||||||
|
revision_note = fields.Char(
|
||||||
|
string='Revision Note',
|
||||||
|
help='What changed in this revision? (e.g. "Updated tolerance on '
|
||||||
|
'feature B per ECN-2024-014".)',
|
||||||
|
)
|
||||||
|
revision_date = fields.Datetime(
|
||||||
|
string='Revision Date', default=fields.Datetime.now, required=True,
|
||||||
|
)
|
||||||
|
new_drawing_file = fields.Binary(
|
||||||
|
string='New Drawing (PDF, optional)',
|
||||||
|
help='Drop a PDF drawing here. It will be added to the new '
|
||||||
|
'revision\'s drawing list. Leave empty to inherit the '
|
||||||
|
'existing drawings.',
|
||||||
|
)
|
||||||
|
new_drawing_filename = fields.Char(string='Drawing Filename')
|
||||||
|
new_model_file = fields.Binary(
|
||||||
|
string='New 3D Model (STEP/STL/IGES, optional)',
|
||||||
|
help='Drop a STEP, STP, STL, IGES, IGS, BREP, or BRP file here. '
|
||||||
|
'Replaces the 3D model on the new revision. Leave empty to '
|
||||||
|
'inherit the existing 3D model.',
|
||||||
|
)
|
||||||
|
new_model_filename = fields.Char(string='Model Filename')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Defaults
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
vals = super().default_get(fields_list)
|
||||||
|
# Resolve part_id from context (button passes active_id).
|
||||||
|
part_id = vals.get('part_id') or self.env.context.get('default_part_id') \
|
||||||
|
or self.env.context.get('active_id')
|
||||||
|
if part_id and self.env.context.get('active_model') in (
|
||||||
|
'fp.part.catalog', None,
|
||||||
|
):
|
||||||
|
part = self.env['fp.part.catalog'].browse(part_id)
|
||||||
|
if part.exists():
|
||||||
|
vals['part_id'] = part.id
|
||||||
|
if 'new_revision' in fields_list and not vals.get('new_revision'):
|
||||||
|
vals['new_revision'] = _bump_revision_label(part.revision or '')
|
||||||
|
return vals
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.constrains('new_revision', 'part_id')
|
||||||
|
def _check_new_revision_unique(self):
|
||||||
|
for wiz in self:
|
||||||
|
label = (wiz.new_revision or '').strip()
|
||||||
|
if not label:
|
||||||
|
raise ValidationError(_('New revision label cannot be empty.'))
|
||||||
|
if not wiz.part_id:
|
||||||
|
continue
|
||||||
|
if label == (wiz.part_id.revision or '').strip():
|
||||||
|
raise ValidationError(_(
|
||||||
|
'New revision label must differ from the current '
|
||||||
|
'revision (%s).'
|
||||||
|
) % wiz.part_id.revision)
|
||||||
|
# Uniqueness within the part chain (same root + same label).
|
||||||
|
root = wiz.part_id.parent_part_id or wiz.part_id
|
||||||
|
sibling = self.env['fp.part.catalog'].search([
|
||||||
|
'|',
|
||||||
|
('id', '=', root.id),
|
||||||
|
('parent_part_id', '=', root.id),
|
||||||
|
('revision', '=', label),
|
||||||
|
], limit=1)
|
||||||
|
if sibling:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'A revision "%(rev)s" already exists for this part '
|
||||||
|
'(part %(pn)s). Pick a different label.'
|
||||||
|
) % {'rev': label, 'pn': wiz.part_id.part_number or ''})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Action
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def action_create_revision(self):
|
||||||
|
"""Create the new revision and navigate to it."""
|
||||||
|
self.ensure_one()
|
||||||
|
part = self.part_id
|
||||||
|
if not part:
|
||||||
|
raise UserError(_('No source part selected.'))
|
||||||
|
|
||||||
|
new_label = (self.new_revision or '').strip()
|
||||||
|
part.is_latest_revision = False
|
||||||
|
root = part.parent_part_id or part
|
||||||
|
|
||||||
|
new_part = part.copy({
|
||||||
|
'revision': new_label,
|
||||||
|
'revision_date': self.revision_date or fields.Datetime.now(),
|
||||||
|
'revision_note': self.revision_note or False,
|
||||||
|
'parent_part_id': root.id,
|
||||||
|
'is_latest_revision': True,
|
||||||
|
'model_attachment_id': part.model_attachment_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Optional new PDF drawing — appended to the drawing list.
|
||||||
|
if self.new_drawing_file:
|
||||||
|
drawing_att = self.env['ir.attachment'].create({
|
||||||
|
'name': self.new_drawing_filename or 'drawing.pdf',
|
||||||
|
'datas': self.new_drawing_file,
|
||||||
|
'res_model': 'fp.part.catalog',
|
||||||
|
'res_id': new_part.id,
|
||||||
|
})
|
||||||
|
new_part.drawing_attachment_ids = [(4, drawing_att.id)]
|
||||||
|
|
||||||
|
# Optional new 3D model — replaces the model attachment.
|
||||||
|
if self.new_model_file:
|
||||||
|
model_att = self.env['ir.attachment'].create({
|
||||||
|
'name': self.new_model_filename or 'model.step',
|
||||||
|
'datas': self.new_model_file,
|
||||||
|
'res_model': 'fp.part.catalog',
|
||||||
|
'res_id': new_part.id,
|
||||||
|
})
|
||||||
|
new_part.model_attachment_id = model_att.id
|
||||||
|
|
||||||
|
new_part.message_post(body=_(
|
||||||
|
'Revision %(new)s created from %(old)s. %(note)s'
|
||||||
|
) % {
|
||||||
|
'new': new_label,
|
||||||
|
'old': part.revision or '',
|
||||||
|
'note': self.revision_note or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Part Revision'),
|
||||||
|
'res_model': 'fp.part.catalog',
|
||||||
|
'res_id': new_part.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_part_revision_bump_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.part.revision.bump.wizard.form</field>
|
||||||
|
<field name="model">fp.part.revision.bump.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Create New Revision">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h2>Create New Revision</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
Bump the revision label for
|
||||||
|
<strong><field name="part_id" readonly="1" nolabel="1" options="{'no_open': True}"/></strong>.
|
||||||
|
The pre-filled label is a best-effort guess —
|
||||||
|
adjust it to match the customer's actual scheme.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Revision">
|
||||||
|
<field name="current_revision"/>
|
||||||
|
<field name="new_revision" placeholder="e.g. B, A2, Rev 2, ECO-2024-015"/>
|
||||||
|
<field name="revision_date"/>
|
||||||
|
</group>
|
||||||
|
<group string="Details">
|
||||||
|
<field name="revision_note"
|
||||||
|
placeholder="What changed? (e.g. tolerance update on feature B)"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Updated Files (optional)">
|
||||||
|
<group string="2D Drawing (PDF)">
|
||||||
|
<field name="new_drawing_filename" invisible="1"/>
|
||||||
|
<field name="new_drawing_file"
|
||||||
|
filename="new_drawing_filename"
|
||||||
|
widget="binary"
|
||||||
|
nolabel="1"/>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Added to the new revision's drawing list.
|
||||||
|
Leave empty to inherit the current drawings.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
<group string="3D Model (STEP/STL/IGES)">
|
||||||
|
<field name="new_model_filename" invisible="1"/>
|
||||||
|
<field name="new_model_file"
|
||||||
|
filename="new_model_filename"
|
||||||
|
widget="binary"
|
||||||
|
nolabel="1"/>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Replaces the 3D model on the new revision.
|
||||||
|
Leave empty to inherit the current model.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_create_revision"
|
||||||
|
string="Create Revision"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_part_revision_bump_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Create New Revision</field>
|
||||||
|
<field name="res_model">fp.part.revision.bump.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Invoicing',
|
'name': 'Fusion Plating — Invoicing',
|
||||||
'version': '19.0.3.3.0',
|
'version': '19.0.3.5.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -3,13 +3,22 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import api, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class AccountMove(models.Model):
|
class AccountMove(models.Model):
|
||||||
_inherit = 'account.move'
|
_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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
"""Auto-inherit payment terms + customer PO# at creation time.
|
"""Auto-inherit payment terms + customer PO# at creation time.
|
||||||
@@ -55,17 +64,16 @@ class AccountMove(models.Model):
|
|||||||
"""
|
"""
|
||||||
for move in self:
|
for move in self:
|
||||||
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
||||||
if move.partner_id.x_fc_account_hold:
|
hold_partner = move.partner_id.commercial_partner_id
|
||||||
is_manager = self.env.user.has_group(
|
if hold_partner.x_fc_account_hold:
|
||||||
'fusion_plating.group_fusion_plating_manager'
|
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
|
||||||
)
|
|
||||||
if not is_manager:
|
if not is_manager:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Cannot post invoice — customer "%s" is on account hold.\n'
|
'Cannot post invoice — customer "%s" is on account hold.\n'
|
||||||
'Reason: %s\n\n'
|
'Reason: %s\n\n'
|
||||||
'Contact a manager to override.'
|
'Contact a manager to override.'
|
||||||
) % (move.partner_id.name,
|
) % (hold_partner.name,
|
||||||
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
hold_partner.x_fc_account_hold_reason or 'No reason specified'))
|
||||||
if not move.invoice_payment_term_id:
|
if not move.invoice_payment_term_id:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Cannot post invoice "%s" — no payment terms set.\n\n'
|
'Cannot post invoice "%s" — no payment terms set.\n\n'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class ResPartner(models.Model):
|
class ResPartner(models.Model):
|
||||||
@@ -14,6 +14,25 @@ class ResPartner(models.Model):
|
|||||||
string='Account Hold', tracking=True,
|
string='Account Hold', tracking=True,
|
||||||
help='When active, blocks SO confirmation, invoicing, and shipping.',
|
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_reason = fields.Text(string='Hold Reason')
|
||||||
x_fc_account_hold_date = fields.Datetime(
|
x_fc_account_hold_date = fields.Datetime(
|
||||||
string='Hold Date', help='When the hold was placed.',
|
string='Hold Date', help='When the hold was placed.',
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ _logger = logging.getLogger(__name__)
|
|||||||
class SaleOrder(models.Model):
|
class SaleOrder(models.Model):
|
||||||
_inherit = 'sale.order'
|
_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')
|
@api.onchange('partner_id')
|
||||||
def _onchange_partner_id_invoice_strategy(self):
|
def _onchange_partner_id_invoice_strategy(self):
|
||||||
"""Auto-fill plating defaults from customer profile.
|
"""Auto-fill plating defaults from customer profile.
|
||||||
@@ -119,24 +131,27 @@ class SaleOrder(models.Model):
|
|||||||
) % {'so': order.name})
|
) % {'so': order.name})
|
||||||
|
|
||||||
# --- Account hold check ---
|
# --- Account hold check ---
|
||||||
if order.partner_id.x_fc_account_hold:
|
# Hold lives on the commercial_partner (the company). Resolve
|
||||||
is_manager = self.env.user.has_group(
|
# through that so a hold on the parent applies to every child
|
||||||
'fusion_plating.group_fusion_plating_manager'
|
# 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:
|
if not is_manager:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Cannot confirm — customer "%s" is on account hold.\n'
|
'Cannot confirm — customer "%s" is on account hold.\n'
|
||||||
'Reason: %s\n\n'
|
'Reason: %s\n\n'
|
||||||
'Contact a manager to override.'
|
'Contact a manager to override.'
|
||||||
) % (order.partner_id.name,
|
) % (hold_partner.name,
|
||||||
order.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
hold_partner.x_fc_account_hold_reason or 'No reason specified'))
|
||||||
else:
|
else:
|
||||||
order.message_post(
|
order.message_post(
|
||||||
body=_(
|
body=_(
|
||||||
'Warning: Customer "%s" is on account hold (reason: %s). '
|
'Warning: Customer "%s" is on account hold (reason: %s). '
|
||||||
'Order confirmed by manager override.'
|
'Order confirmed by manager override.'
|
||||||
) % (order.partner_id.name,
|
) % (hold_partner.name,
|
||||||
order.partner_id.x_fc_account_hold_reason or 'N/A'),
|
hold_partner.x_fc_account_hold_reason or 'N/A'),
|
||||||
)
|
)
|
||||||
|
|
||||||
res = super().action_confirm()
|
res = super().action_confirm()
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//form/header" position="before">
|
<xpath expr="//form/header" position="before">
|
||||||
|
<field name="x_fc_partner_account_hold" invisible="1"/>
|
||||||
<div class="alert alert-danger py-1 px-2 mb-0 small"
|
<div class="alert alert-danger py-1 px-2 mb-0 small"
|
||||||
role="alert"
|
role="alert"
|
||||||
invisible="not partner_id or not partner_id.x_fc_account_hold">
|
invisible="not x_fc_partner_account_hold">
|
||||||
<i class="fa fa-ban me-1"/>
|
<i class="fa fa-ban me-1" title="Account hold"/>
|
||||||
<strong>Account Hold</strong> — SO confirmation, invoicing
|
<strong>Account Hold</strong> — SO confirmation, invoicing
|
||||||
and shipping are blocked for non-managers.
|
and shipping are blocked for non-managers.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.18.4',
|
'version': '19.0.8.20.6',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'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
|
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
|
||||||
module family — configurator, portal, logistics, quality, certificates.
|
module family — configurator, portal, logistics, quality, certificates.
|
||||||
|
|
||||||
Coexists with fusion_plating_bridge_mrp during the migration period.
|
As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only
|
||||||
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
|
fulfilment path. SO confirm always creates fp.job records here. The
|
||||||
False). When False, SO confirm continues to create mrp.production records
|
former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0
|
||||||
through bridge_mrp. When True, SO confirm creates fp.job records here.
|
once the legacy fallback became unreachable.
|
||||||
|
|
||||||
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
|
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
|
||||||
OWL/controller stack (job_process_tree, job_plant_overview,
|
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).
|
# so the statusbar's m2o has its targets available at view-render time).
|
||||||
'data/fp_workflow_state_data.xml',
|
'data/fp_workflow_state_data.xml',
|
||||||
'views/fp_workflow_state_views.xml',
|
'views/fp_workflow_state_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
|
||||||
'views/fp_job_step_quick_look_views.xml',
|
'views/fp_job_step_quick_look_views.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
'views/fp_job_quality_buttons.xml',
|
'views/fp_job_quality_buttons.xml',
|
||||||
|
|||||||
@@ -57,6 +57,37 @@ class FpRecordInputsController(http.Controller):
|
|||||||
'is_authored': True,
|
'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 {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'step': {
|
'step': {
|
||||||
@@ -68,13 +99,16 @@ class FpRecordInputsController(http.Controller):
|
|||||||
'name': step.job_id.name,
|
'name': step.job_id.name,
|
||||||
},
|
},
|
||||||
'prompts': prompts,
|
'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)
|
# Commit — write values via the existing wizard (reuse semantics)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/record_inputs/commit', type='jsonrpc', auth='user')
|
@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.
|
"""Commit operator-entered values for this step.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -148,6 +182,17 @@ class FpRecordInputsController(http.Controller):
|
|||||||
if advance_after:
|
if advance_after:
|
||||||
ctx['fp_advance_after_save'] = True
|
ctx['fp_advance_after_save'] = True
|
||||||
result = wizard.with_context(**ctx).action_commit()
|
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 {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'next_action': result if isinstance(result, dict) else False,
|
'next_action': result if isinstance(result, dict) else False,
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ from . import fp_job_step
|
|||||||
from . import fp_job_node_override
|
from . import fp_job_node_override
|
||||||
from . import fp_portal_job
|
from . import fp_portal_job
|
||||||
from . import account_move
|
from . import account_move
|
||||||
from . import res_config_settings
|
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
from . import sale_order_line
|
from . import sale_order_line
|
||||||
|
from . import res_users
|
||||||
|
|
||||||
# Phase 3 — parallel job/step links on dependent modules' models.
|
# Phase 3 — parallel job/step links on dependent modules' models.
|
||||||
from . import fp_batch
|
from . import fp_batch
|
||||||
|
|||||||
@@ -367,6 +367,16 @@ class FpJobStep(models.Model):
|
|||||||
if cr_action:
|
if cr_action:
|
||||||
return 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
|
# Prompt-first behaviour: show the Record Inputs dialog when the
|
||||||
# recipe step has authored prompts and nothing has been captured
|
# recipe step has authored prompts and nothing has been captured
|
||||||
# in this run. Bypass when context flag is set (i.e. we're being
|
# 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):
|
def _fp_open_contract_review(self):
|
||||||
"""Auto-create the QA-005 form for this step's part if missing,
|
"""Auto-create the QA-005 form for this step's part if missing,
|
||||||
return the act_window pointing at it. Called from button_start
|
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()
|
self.ensure_one()
|
||||||
|
if self.env.context.get('fp_skip_contract_review_gate'):
|
||||||
|
return None
|
||||||
part = self._fp_resolve_contract_review_part()
|
part = self._fp_resolve_contract_review_part()
|
||||||
if not part:
|
if not part:
|
||||||
return None
|
return None
|
||||||
|
if not part.partner_id.x_fc_contract_review_required:
|
||||||
|
return None
|
||||||
Review = self.env.get('fp.contract.review')
|
Review = self.env.get('fp.contract.review')
|
||||||
if Review is None:
|
if Review is None:
|
||||||
return None # quality module not installed — skip
|
return None # quality module not installed — skip
|
||||||
review = part.x_fc_contract_review_id
|
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:
|
if not review:
|
||||||
review = Review.sudo().create({
|
review = Review.sudo().create({
|
||||||
'part_id': part.id,
|
'part_id': part.id,
|
||||||
@@ -767,6 +796,46 @@ class FpJobStep(models.Model):
|
|||||||
'name': _('Racking Inspection — %s') % self.job_id.name,
|
'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):
|
def _fp_check_racking_inspection_complete(self):
|
||||||
"""Soft gate — block button_finish on a Racking step until the
|
"""Soft gate — block button_finish on a Racking step until the
|
||||||
linked inspection is in a terminal state. discrepancy_flagged
|
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
|
"""Return an ir.actions.act_window opening the part's QA-005
|
||||||
Contract Review form, or False to indicate "no redirect needed".
|
Contract Review form, or False to indicate "no redirect needed".
|
||||||
|
|
||||||
Triggers when:
|
Triggers when ALL of these are true:
|
||||||
* the recipe node is flagged default_kind='contract_review', AND
|
* the step is a Contract Review step (matched via
|
||||||
* the linked part has no review yet OR the review is still in
|
``_fp_is_contract_review_step`` — name OR template kind OR
|
||||||
a non-terminal state (draft / assistant_review / manager_review).
|
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
|
Once the review reaches state 'complete' or 'dismissed' the
|
||||||
is allowed to finish through the normal path, which is how the
|
step is allowed to finish through the normal path. This is how
|
||||||
operator clears the contract-review gate after signing QA-005.
|
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
|
Resolution mirrors ``_fp_check_contract_review_complete`` so a
|
||||||
a per-part review, so we fall through to the standard wizard
|
single source of truth governs both ENTRY (this redirect) and
|
||||||
rather than blocking the operator.
|
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()
|
self.ensure_one()
|
||||||
node = self.recipe_node_id
|
# Manager bypass — same context flag the gate honours.
|
||||||
if not node or node.default_kind != 'contract_review':
|
if self.env.context.get('fp_skip_contract_review_gate'):
|
||||||
return False
|
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:
|
if not part:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Contract-review step '%s' on job %s has no part_catalog_id "
|
"Contract-review step '%s' on job %s has no part — "
|
||||||
"— cannot redirect to QA-005 form, falling through to "
|
"cannot redirect to QA-005 form, falling through to "
|
||||||
"standard wizard.",
|
"standard wizard.",
|
||||||
self.name, self.job_id.name,
|
self.name, self.job_id.name,
|
||||||
)
|
)
|
||||||
return False
|
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
|
review = part.x_fc_contract_review_id
|
||||||
if review and review.state in ('complete', 'dismissed'):
|
if review and review.state in ('complete', 'dismissed'):
|
||||||
return False
|
return False
|
||||||
@@ -1022,6 +1110,28 @@ class FpJobStep(models.Model):
|
|||||||
related='recipe_node_id.collect_measurements',
|
related='recipe_node_id.collect_measurements',
|
||||||
readonly=True,
|
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(
|
quick_look_prompt_ids = fields.Many2many(
|
||||||
'fusion.plating.process.node.input',
|
'fusion.plating.process.node.input',
|
||||||
string='Prompts',
|
string='Prompts',
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
)
|
|
||||||
40
fusion_plating/fusion_plating_jobs/models/res_users.py
Normal file
40
fusion_plating/fusion_plating_jobs/models/res_users.py
Normal file
@@ -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()
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
#
|
#
|
||||||
# sale.order.action_confirm hook — creates fp.job records when the
|
# sale.order.action_confirm hook — creates fp.job records on confirm.
|
||||||
# x_fc_use_native_jobs setting is True. Mirrors bridge_mrp's
|
# Sub 11 (2026-04-26) removed MRP entirely; fp.job is the only fulfilment
|
||||||
# _fp_auto_create_mo but creates fp.job instead of mrp.production.
|
# 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.
|
||||||
# When the setting is False (default), this hook is a no-op and
|
|
||||||
# bridge_mrp's MO-creation hook handles the flow.
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -82,18 +80,7 @@ class SaleOrder(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _compute_workflow_stage(self):
|
def _compute_workflow_stage(self):
|
||||||
"""Native-jobs override — walks fp.job state instead of mrp.production.
|
"""Walk fp.job state to derive the SO workflow banner."""
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
Job = self.env['fp.job']
|
Job = self.env['fp.job']
|
||||||
Delivery = self.env.get('fusion.plating.delivery')
|
Delivery = self.env.get('fusion.plating.delivery')
|
||||||
for so in self:
|
for so in self:
|
||||||
@@ -201,9 +188,6 @@ class SaleOrder(models.Model):
|
|||||||
|
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
result = super().action_confirm()
|
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:
|
for so in self:
|
||||||
so._fp_auto_create_job()
|
so._fp_auto_create_job()
|
||||||
# Auto-confirm any draft jobs we just created so steps
|
# Auto-confirm any draft jobs we just created so steps
|
||||||
|
|||||||
@@ -29,7 +29,31 @@ import { _t } from "@web/core/l10n/translation";
|
|||||||
const NUMERIC_TYPES = new Set([
|
const NUMERIC_TYPES = new Set([
|
||||||
"number", "temperature", "thickness", "time_seconds", "ph",
|
"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 {
|
export class FpRecordInputsDialog extends Component {
|
||||||
@@ -46,6 +70,18 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
stepName: "",
|
stepName: "",
|
||||||
jobName: "",
|
jobName: "",
|
||||||
rows: [],
|
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 () => {
|
onWillStart(async () => {
|
||||||
await this.loadPrompts();
|
await this.loadPrompts();
|
||||||
@@ -67,7 +103,12 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
}
|
}
|
||||||
this.state.stepName = data.step.name;
|
this.state.stepName = data.step.name;
|
||||||
this.state.jobName = data.job.name;
|
this.state.jobName = data.job.name;
|
||||||
this.state.rows = data.prompts.map((p) => ({
|
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,
|
...p,
|
||||||
// value fields — initialized blank, populated as operator types
|
// value fields — initialized blank, populated as operator types
|
||||||
value_text: "",
|
value_text: "",
|
||||||
@@ -80,10 +121,68 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
point_4: 0, point_5: 0,
|
point_4: 0, point_5: 0,
|
||||||
panel_ph: 0, panel_concentration: 0,
|
panel_ph: 0, panel_concentration: 0,
|
||||||
panel_temperature: 0, panel_bath_id: "",
|
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;
|
this.state.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when this row should be auto-populated from
|
||||||
|
// ``state.userInitials``. Driven by input_type or a name keyword
|
||||||
|
// so it works for "Reviewer Initials" (text), "Inspector Signature"
|
||||||
|
// (signature), "Operator Initials" (text), etc.
|
||||||
|
_fpIsInitialsField(row) {
|
||||||
|
if (this.isSignature(row)) return true;
|
||||||
|
if ((row.input_type || "") === "text") {
|
||||||
|
const name = (row.name || "").toLowerCase();
|
||||||
|
return name.includes("initial");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current local datetime as "YYYY-MM-DDTHH:MM" (the format the
|
||||||
|
// <input type="datetime-local"> widget accepts in t-model).
|
||||||
|
_fpNowForDatetimeLocal() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return [
|
||||||
|
d.getFullYear(),
|
||||||
|
"-", pad(d.getMonth() + 1),
|
||||||
|
"-", pad(d.getDate()),
|
||||||
|
"T", pad(d.getHours()),
|
||||||
|
":", pad(d.getMinutes()),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Type predicates (used by the OWL template t-if) ----------------
|
// ---- Type predicates (used by the OWL template t-if) ----------------
|
||||||
isNumeric(row) { return NUMERIC_TYPES.has(row.input_type); }
|
isNumeric(row) { return NUMERIC_TYPES.has(row.input_type); }
|
||||||
isBoolean(row) { return BOOLEAN_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"; }
|
isMulti(row) { return row.input_type === "multi_point_thickness"; }
|
||||||
isPanel(row) { return row.input_type === "bath_chemistry_panel"; }
|
isPanel(row) { return row.input_type === "bath_chemistry_panel"; }
|
||||||
isSelection(row) { return row.input_type === "selection"; }
|
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) {
|
isText(row) {
|
||||||
return !this.isNumeric(row) && !this.isBoolean(row)
|
return !this.isNumeric(row) && !this.isBoolean(row)
|
||||||
&& !this.isDate(row) && !this.isPhoto(row)
|
&& !this.isDate(row) && !this.isPhoto(row)
|
||||||
&& !this.isMulti(row) && !this.isPanel(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 ------
|
// ---- 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") };
|
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) {
|
async onPhotoChange(row, ev) {
|
||||||
const file = ev.target.files[0];
|
const file = ev.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -171,6 +365,7 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
point_4: 0, point_5: 0,
|
point_4: 0, point_5: 0,
|
||||||
panel_ph: 0, panel_concentration: 0,
|
panel_ph: 0, panel_concentration: 0,
|
||||||
panel_temperature: 0, panel_bath_id: "",
|
panel_temperature: 0, panel_bath_id: "",
|
||||||
|
_passfail_chosen: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +373,21 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
this.state.rows.splice(idx, 1);
|
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 ----------------------------------------------------------
|
// ---- Save ----------------------------------------------------------
|
||||||
async onSave() {
|
async onSave() {
|
||||||
// Validate ad-hoc rows have a prompt name
|
// Validate ad-hoc rows have a prompt name
|
||||||
@@ -190,18 +400,59 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
return;
|
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;
|
this.state.saving = true;
|
||||||
const payload = this.state.rows.map((r) => ({
|
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,
|
node_input_id: r.node_input_id || false,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
input_type: r.input_type,
|
input_type: r.input_type,
|
||||||
target_unit: r.target_unit,
|
target_unit: r.target_unit,
|
||||||
target_min: r.target_min,
|
target_min: r.target_min,
|
||||||
target_max: r.target_max,
|
target_max: r.target_max,
|
||||||
value_text: r.value_text || false,
|
value_text: valueText,
|
||||||
value_number: r.value_number || 0,
|
value_number: valueNumber,
|
||||||
value_boolean: r.value_boolean,
|
value_boolean: r.value_boolean,
|
||||||
value_date: r.value_date || false,
|
// 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_value: r.photo_value || false,
|
||||||
photo_filename: r.photo_filename || false,
|
photo_filename: r.photo_filename || false,
|
||||||
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
|
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
|
||||||
@@ -210,11 +461,13 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
panel_concentration: r.panel_concentration,
|
panel_concentration: r.panel_concentration,
|
||||||
panel_temperature: r.panel_temperature,
|
panel_temperature: r.panel_temperature,
|
||||||
panel_bath_id: r.panel_bath_id,
|
panel_bath_id: r.panel_bath_id,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
const result = await rpc("/fp/record_inputs/commit", {
|
const result = await rpc("/fp/record_inputs/commit", {
|
||||||
step_id: this.props.stepId,
|
step_id: this.props.stepId,
|
||||||
values: payload,
|
values: payload,
|
||||||
advance_after: !!this.props.advanceAfter,
|
advance_after: !!this.props.advanceAfter,
|
||||||
|
user_initials: this._fpCollectInitials(),
|
||||||
});
|
});
|
||||||
this.state.saving = false;
|
this.state.saving = false;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -229,9 +482,23 @@ export class FpRecordInputsDialog extends Component {
|
|||||||
{ type: "success" },
|
{ type: "success" },
|
||||||
);
|
);
|
||||||
this.props.close();
|
this.props.close();
|
||||||
// If commit returned an action (e.g. Finish & Advance), dispatch it
|
// Dispatch a meaningful next action when the backend returns one
|
||||||
if (result.next_action && typeof result.next_action === "object") {
|
// (e.g. opening another form). Otherwise — and for the no-op
|
||||||
await this.action.doAction(result.next_action);
|
// 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",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,10 +223,42 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
|
|||||||
|
|
||||||
// ---------- Target / hint helpers ------------------------------------------
|
// ---------- 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 {
|
.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;
|
font-size: 0.8125rem;
|
||||||
|
color: $rid-ok;
|
||||||
|
|
||||||
|
.fa-bullseye { color: $rid-ok; }
|
||||||
|
|
||||||
|
.o_fp_ri_target_label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_ri_target_value {
|
||||||
|
color: $rid-ink;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_ri_target_unit {
|
||||||
|
margin-left: 2px;
|
||||||
color: $rid-ink-mute;
|
color: $rid-ink-mute;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.o_fp_ri_hint {
|
.o_fp_ri_hint {
|
||||||
margin: 0 0 8px 0;
|
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
|
// 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);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,16 +20,39 @@
|
|||||||
<span class="ms-2">Loading prompts...</span>
|
<span class="ms-2">Loading prompts...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Instructions block — recipe-author HTML + image gallery shown
|
||||||
<div t-elif="!state.rows.length" class="o_fp_ri_empty">
|
above the prompt cards so the operator reads context BEFORE
|
||||||
|
entering values. Hidden when neither is authored. -->
|
||||||
|
<div t-if="!state.loading and (state.instructionsHtml or state.instructionImages.length)"
|
||||||
|
class="o_fp_ri_instructions">
|
||||||
|
<div t-if="state.instructionsHtml"
|
||||||
|
class="o_fp_ri_instructions_text"
|
||||||
|
t-out="state.instructionsHtml"/>
|
||||||
|
<div t-if="state.instructionImages.length"
|
||||||
|
class="o_fp_ri_instructions_gallery">
|
||||||
|
<t t-foreach="state.instructionImages" t-as="img" t-key="img.id">
|
||||||
|
<a t-att-href="img.url"
|
||||||
|
target="_blank"
|
||||||
|
class="o_fp_ri_instructions_thumb"
|
||||||
|
t-att-title="img.name">
|
||||||
|
<img t-att-src="img.url" t-att-alt="img.name"/>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state. Independent t-if (not t-elif) so the
|
||||||
|
instructions block above doesn't break the chain — the
|
||||||
|
cards / empty branch must only depend on loading + rows. -->
|
||||||
|
<div t-if="!state.loading and !state.rows.length" class="o_fp_ri_empty">
|
||||||
<p>No measurement prompts on this step.</p>
|
<p>No measurement prompts on this step.</p>
|
||||||
<button class="btn btn-secondary" t-on-click="addAdHocRow">
|
<button class="btn btn-secondary" t-on-click="addAdHocRow">
|
||||||
<i class="fa fa-plus me-1"/> Add a measurement
|
<i class="fa fa-plus me-1"/> Add a measurement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cards -->
|
<!-- Cards. Same fix — independent t-if. -->
|
||||||
<div t-else="" class="o_fp_ri_cards">
|
<div t-if="!state.loading and state.rows.length" class="o_fp_ri_cards">
|
||||||
<t t-foreach="state.rows" t-as="row" t-key="row_index">
|
<t t-foreach="state.rows" t-as="row" t-key="row_index">
|
||||||
<div class="o_fp_ri_card"
|
<div class="o_fp_ri_card"
|
||||||
t-att-class="{ 'o_fp_ri_card_required': row.required }">
|
t-att-class="{ 'o_fp_ri_card_required': row.required }">
|
||||||
@@ -53,7 +76,7 @@
|
|||||||
|
|
||||||
<div class="o_fp_ri_meta">
|
<div class="o_fp_ri_meta">
|
||||||
<span class="o_fp_ri_pill o_fp_ri_pill_type"
|
<span class="o_fp_ri_pill o_fp_ri_pill_type"
|
||||||
t-esc="row.input_type"/>
|
t-esc="inputTypeLabel(row)"/>
|
||||||
<span t-if="row.target_unit"
|
<span t-if="row.target_unit"
|
||||||
class="o_fp_ri_pill o_fp_ri_pill_unit"
|
class="o_fp_ri_pill o_fp_ri_pill_unit"
|
||||||
t-esc="row.target_unit"/>
|
t-esc="row.target_unit"/>
|
||||||
@@ -67,14 +90,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Target range hint (if recipe author set one) -->
|
<!-- Target range hint (any prompt with a target_min /
|
||||||
<div t-if="(row.target_min or row.target_max) and isNumeric(row)"
|
target_max — numeric, pass_fail, etc.). Renders
|
||||||
|
as a small "Target: 0.005 – 0.007 in" pill so the
|
||||||
|
operator can see the spec before they enter
|
||||||
|
readings. -->
|
||||||
|
<div t-if="row.target_min or row.target_max"
|
||||||
class="o_fp_ri_target">
|
class="o_fp_ri_target">
|
||||||
Target:
|
<i class="fa fa-bullseye me-1"/>
|
||||||
<strong>
|
<span class="o_fp_ri_target_label">Target</span>
|
||||||
|
<strong class="o_fp_ri_target_value">
|
||||||
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> – </t><t t-if="row.target_max" t-esc="row.target_max"/>
|
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> – </t><t t-if="row.target_max" t-esc="row.target_max"/>
|
||||||
</strong>
|
</strong>
|
||||||
<span t-if="row.target_unit" class="ms-1 text-muted" t-esc="row.target_unit"/>
|
<span t-if="row.target_unit" class="o_fp_ri_target_unit" t-esc="row.target_unit"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hint text from recipe author -->
|
<!-- Hint text from recipe author -->
|
||||||
@@ -83,8 +111,9 @@
|
|||||||
<!-- Card body — live input widget per type -->
|
<!-- Card body — live input widget per type -->
|
||||||
<div class="o_fp_ri_card_body">
|
<div class="o_fp_ri_card_body">
|
||||||
|
|
||||||
<!-- Numeric (number, temperature, thickness, time_seconds, ph) -->
|
<!-- Numeric — single value (no range defined) -->
|
||||||
<div t-if="isNumeric(row)" class="o_fp_ri_numeric">
|
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
|
||||||
|
class="o_fp_ri_numeric">
|
||||||
<input type="number"
|
<input type="number"
|
||||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -97,7 +126,109 @@
|
|||||||
t-esc="hint.text"/>
|
t-esc="hint.text"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boolean / pass-fail toggle -->
|
<!-- Numeric — dual entry (recipe author defined a
|
||||||
|
min and max target → operator records both
|
||||||
|
observed extremes from their measurements).
|
||||||
|
Constrained to numeric so it doesn't duplicate
|
||||||
|
the pass_fail+range branch above. -->
|
||||||
|
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
|
||||||
|
<label class="o_fp_ri_dual_field">
|
||||||
|
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||||
|
<input type="number"
|
||||||
|
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||||
|
step="any"
|
||||||
|
t-model.number="row.value_min"
|
||||||
|
t-att-placeholder="row.target_min or '0.00'"/>
|
||||||
|
</label>
|
||||||
|
<label class="o_fp_ri_dual_field">
|
||||||
|
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||||
|
<input type="number"
|
||||||
|
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||||
|
step="any"
|
||||||
|
t-model.number="row.value_max"
|
||||||
|
t-att-placeholder="row.target_max or '0.00'"/>
|
||||||
|
</label>
|
||||||
|
<t t-set="dhint" t-value="dualRangeHint(row)"/>
|
||||||
|
<span t-if="dhint"
|
||||||
|
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
|
||||||
|
t-att-class="'o_fp_ri_range_' + dhint.kind"
|
||||||
|
t-esc="dhint.text"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pass / Fail with range — operator records min
|
||||||
|
+ max measurements first, system suggests the
|
||||||
|
verdict, then operator confirms with PASS/FAIL.
|
||||||
|
This branch fires when the recipe author
|
||||||
|
defined target_min / target_max on a pass_fail
|
||||||
|
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
|
||||||
|
<t t-if="isPassFail(row) and hasRangeEntry(row)">
|
||||||
|
<div class="o_fp_ri_dual">
|
||||||
|
<label class="o_fp_ri_dual_field">
|
||||||
|
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||||
|
<input type="number"
|
||||||
|
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||||
|
step="any"
|
||||||
|
t-model.number="row.value_min"
|
||||||
|
t-att-placeholder="row.target_min or '0.00'"/>
|
||||||
|
</label>
|
||||||
|
<label class="o_fp_ri_dual_field">
|
||||||
|
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||||
|
<input type="number"
|
||||||
|
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||||
|
step="any"
|
||||||
|
t-model.number="row.value_max"
|
||||||
|
t-att-placeholder="row.target_max or '0.00'"/>
|
||||||
|
</label>
|
||||||
|
<t t-set="dhint" t-value="dualRangeHint(row)"/>
|
||||||
|
<span t-if="dhint"
|
||||||
|
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
|
||||||
|
t-att-class="'o_fp_ri_range_' + dhint.kind"
|
||||||
|
t-esc="dhint.text"/>
|
||||||
|
</div>
|
||||||
|
<t t-set="sugg" t-value="suggestedPassFail(row)"/>
|
||||||
|
<div t-if="sugg" class="o_fp_ri_pf_suggest"
|
||||||
|
t-att-class="'o_fp_ri_pf_suggest_' + sugg">
|
||||||
|
<i t-att-class="sugg === 'pass' ? 'fa fa-check-circle me-1' : 'fa fa-exclamation-triangle me-1'"/>
|
||||||
|
Readings suggest <strong t-esc="sugg.toUpperCase()"/> — confirm below.
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_ri_passfail">
|
||||||
|
<button type="button"
|
||||||
|
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
|
||||||
|
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
|
||||||
|
t-on-click="() => this.onPass(row)">
|
||||||
|
<i class="fa fa-check me-2"/> PASS
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
|
||||||
|
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
|
||||||
|
t-on-click="() => this.onFail(row)">
|
||||||
|
<i class="fa fa-times me-2"/> FAIL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Pass / Fail without range — distinct two-button
|
||||||
|
widget so the operator sees the OUTCOME, not a
|
||||||
|
generic toggle. Active button fills with green
|
||||||
|
(PASS) or red (FAIL); the inactive one stays
|
||||||
|
outlined. -->
|
||||||
|
<div t-if="isPassFail(row) and !hasRangeEntry(row)"
|
||||||
|
class="o_fp_ri_passfail">
|
||||||
|
<button type="button"
|
||||||
|
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
|
||||||
|
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
|
||||||
|
t-on-click="() => this.onPass(row)">
|
||||||
|
<i class="fa fa-check me-2"/> PASS
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
|
||||||
|
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
|
||||||
|
t-on-click="() => this.onFail(row)">
|
||||||
|
<i class="fa fa-times me-2"/> FAIL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic boolean toggle (Yes / No) -->
|
||||||
<label t-if="isBoolean(row)" class="o_fp_ri_toggle">
|
<label t-if="isBoolean(row)" class="o_fp_ri_toggle">
|
||||||
<input type="checkbox" t-model="row.value_boolean"/>
|
<input type="checkbox" t-model="row.value_boolean"/>
|
||||||
<span class="o_fp_ri_toggle_track">
|
<span class="o_fp_ri_toggle_track">
|
||||||
@@ -114,14 +245,36 @@
|
|||||||
t-model="row.value_date"/>
|
t-model="row.value_date"/>
|
||||||
|
|
||||||
<!-- Selection (uses recipe author's selection_options) -->
|
<!-- Selection (uses recipe author's selection_options) -->
|
||||||
<select t-if="isSelection(row)"
|
<t t-if="isSelection(row)">
|
||||||
|
<t t-set="opts" t-value="selectionOptions(row)"/>
|
||||||
|
<select t-if="opts.length"
|
||||||
class="o_fp_ri_input o_fp_ri_input_select"
|
class="o_fp_ri_input o_fp_ri_input_select"
|
||||||
t-model="row.value_text">
|
t-model="row.value_text">
|
||||||
<option value="">— choose —</option>
|
<option value="">— choose —</option>
|
||||||
<t t-foreach="selectionOptions(row)" t-as="opt" t-key="opt">
|
<t t-foreach="opts" t-as="opt" t-key="opt">
|
||||||
<option t-att-value="opt" t-esc="opt"/>
|
<option t-att-value="opt" t-esc="opt"/>
|
||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
|
<div t-else="" class="o_fp_ri_select_empty">
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
No options configured for this prompt — type a value below.
|
||||||
|
<input type="text"
|
||||||
|
class="o_fp_ri_input o_fp_ri_input_text mt-2"
|
||||||
|
t-model="row.value_text"
|
||||||
|
placeholder="Enter value…"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Signature — distinct affordance so the operator
|
||||||
|
knows initials are required (not free text). -->
|
||||||
|
<div t-if="isSignature(row)" class="o_fp_ri_signature">
|
||||||
|
<i class="fa fa-pencil-square-o o_fp_ri_signature_icon"/>
|
||||||
|
<input type="text"
|
||||||
|
class="o_fp_ri_input o_fp_ri_input_signature"
|
||||||
|
t-model="row.value_text"
|
||||||
|
placeholder="Type your initials (e.g. JD)"
|
||||||
|
maxlength="10"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Photo upload -->
|
<!-- Photo upload -->
|
||||||
<div t-if="isPhoto(row)" class="o_fp_ri_photo">
|
<div t-if="isPhoto(row)" class="o_fp_ri_photo">
|
||||||
|
|||||||
@@ -286,16 +286,7 @@ class TestSoConfirmHook(TransactionCase):
|
|||||||
self.env['sale.order.line'].create(line_defaults)
|
self.env['sale.order.line'].create(line_defaults)
|
||||||
return so
|
return so
|
||||||
|
|
||||||
def test_flag_off_no_job_created(self):
|
def test_so_confirm_creates_job(self):
|
||||||
# Default flag is False
|
|
||||||
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'False')
|
|
||||||
so = self._make_so_with_plating_line()
|
|
||||||
so.action_confirm()
|
|
||||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
|
||||||
self.assertFalse(jobs)
|
|
||||||
|
|
||||||
def test_flag_on_creates_job(self):
|
|
||||||
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True')
|
|
||||||
# Need a plating line — add x_fc_part_catalog_id if available
|
# Need a plating line — add x_fc_part_catalog_id if available
|
||||||
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
|
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
|
||||||
partner_for_part = self.env['res.partner'].create({'name': 'PartOwner'})
|
partner_for_part = self.env['res.partner'].create({'name': 'PartOwner'})
|
||||||
@@ -313,8 +304,7 @@ class TestSoConfirmHook(TransactionCase):
|
|||||||
else:
|
else:
|
||||||
self.skipTest('x_fc_part_catalog_id field not present on sale.order.line')
|
self.skipTest('x_fc_part_catalog_id field not present on sale.order.line')
|
||||||
|
|
||||||
def test_flag_on_idempotent(self):
|
def test_so_confirm_idempotent(self):
|
||||||
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True')
|
|
||||||
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
|
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
|
||||||
partner_for_part = self.env['res.partner'].create({'name': 'PO'})
|
partner_for_part = self.env['res.partner'].create({'name': 'PO'})
|
||||||
part = self.env['fp.part.catalog'].create({
|
part = self.env['fp.part.catalog'].create({
|
||||||
|
|||||||
@@ -19,12 +19,23 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Step Details" edit="false" create="false" delete="false">
|
<form string="Step Details" edit="false" create="false" delete="false">
|
||||||
<sheet>
|
<sheet>
|
||||||
|
<!-- Hidden helper fields used by section visibility
|
||||||
|
conditions below. Without these the empty-state
|
||||||
|
hides for Equipment / Schedule won't evaluate. -->
|
||||||
|
<field name="work_centre_id" invisible="1"/>
|
||||||
|
<field name="tank_id" invisible="1"/>
|
||||||
|
<field name="bath_id" invisible="1"/>
|
||||||
|
<field name="rack_id" invisible="1"/>
|
||||||
|
<field name="duration_expected" invisible="1"/>
|
||||||
|
<field name="duration_actual" invisible="1"/>
|
||||||
|
<field name="assigned_user_id" invisible="1"/>
|
||||||
|
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" readonly="1"/>
|
<field name="name" readonly="1"/>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<field name="sequence" readonly="1"/> ·
|
Step #<field name="sequence" readonly="1"/> ·
|
||||||
<field name="kind" readonly="1"/> ·
|
<field name="kind" readonly="1"/> ·
|
||||||
<field name="state" widget="badge"
|
<field name="state" widget="badge"
|
||||||
decoration-info="state == 'in_progress'"
|
decoration-info="state == 'in_progress'"
|
||||||
@@ -34,17 +45,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Job context — what job is this step part of, who's
|
||||||
|
the customer, what part, how many. The single most
|
||||||
|
useful thing to surface up top so the operator
|
||||||
|
orients themselves before drilling in. -->
|
||||||
|
<group string="Job Context">
|
||||||
<group>
|
<group>
|
||||||
<group string="Equipment">
|
<field name="job_id" readonly="1" options="{'no_open': True}"/>
|
||||||
<field name="work_centre_id" readonly="1"/>
|
<field name="quick_look_part_catalog_id" readonly="1"
|
||||||
<field name="tank_id" readonly="1"/>
|
options="{'no_open': True}"
|
||||||
<field name="bath_id" readonly="1"/>
|
invisible="not quick_look_part_catalog_id"/>
|
||||||
<field name="rack_id" readonly="1"/>
|
|
||||||
</group>
|
</group>
|
||||||
<group string="Schedule">
|
<group>
|
||||||
<field name="duration_expected" readonly="1"/>
|
<field name="quick_look_partner_id" readonly="1"
|
||||||
<field name="duration_actual" readonly="1"/>
|
options="{'no_open': True}"
|
||||||
<field name="assigned_user_id" readonly="1"/>
|
invisible="not quick_look_partner_id"/>
|
||||||
|
<field name="quick_look_qty" readonly="1"
|
||||||
|
invisible="not quick_look_qty"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Equipment / Schedule — only render when there's
|
||||||
|
actually something to show. An Inspection step with
|
||||||
|
no tank / bath / time-budget shouldn't display
|
||||||
|
four empty rows of "—" — that's misleading. -->
|
||||||
|
<group invisible="not work_centre_id and not tank_id and not bath_id and not rack_id and not duration_expected and not duration_actual and not assigned_user_id">
|
||||||
|
<group string="Equipment"
|
||||||
|
invisible="not work_centre_id and not tank_id and not bath_id and not rack_id">
|
||||||
|
<field name="work_centre_id" readonly="1"
|
||||||
|
invisible="not work_centre_id"/>
|
||||||
|
<field name="tank_id" readonly="1"
|
||||||
|
invisible="not tank_id"/>
|
||||||
|
<field name="bath_id" readonly="1"
|
||||||
|
invisible="not bath_id"/>
|
||||||
|
<field name="rack_id" readonly="1"
|
||||||
|
invisible="not rack_id"/>
|
||||||
|
</group>
|
||||||
|
<group string="Schedule"
|
||||||
|
invisible="not duration_expected and not duration_actual and not assigned_user_id">
|
||||||
|
<field name="duration_expected" readonly="1"
|
||||||
|
invisible="not duration_expected"/>
|
||||||
|
<field name="duration_actual" readonly="1"
|
||||||
|
invisible="not duration_actual"/>
|
||||||
|
<field name="assigned_user_id" readonly="1"
|
||||||
|
invisible="not assigned_user_id"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
@@ -57,14 +101,24 @@
|
|||||||
<strong> Master switch off</strong> — no values will be collected at runtime for this step.
|
<strong> Master switch off</strong> — no values will be collected at runtime for this step.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<separator string="Operator Instructions"/>
|
<!-- Operator Instructions — hide the whole section when
|
||||||
<div class="o_fp_quick_look_instructions">
|
the recipe author didn't write any. -->
|
||||||
|
<separator string="Operator Instructions"
|
||||||
|
invisible="not quick_look_instructions"/>
|
||||||
|
<div class="o_fp_quick_look_instructions"
|
||||||
|
invisible="not quick_look_instructions">
|
||||||
<field name="quick_look_instructions" nolabel="1" readonly="1"/>
|
<field name="quick_look_instructions" nolabel="1" readonly="1"/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small"
|
|
||||||
invisible="quick_look_instructions">
|
<!-- Instruction images — visual reference photos /
|
||||||
No instructions authored for this step.
|
screenshots the recipe author attached to the
|
||||||
</p>
|
node. Hidden when none. -->
|
||||||
|
<separator string="Reference Images"
|
||||||
|
invisible="not quick_look_instruction_attachment_ids"/>
|
||||||
|
<field name="quick_look_instruction_attachment_ids"
|
||||||
|
nolabel="1" readonly="1"
|
||||||
|
widget="many2many_binary"
|
||||||
|
invisible="not quick_look_instruction_attachment_ids"/>
|
||||||
|
|
||||||
<separator string="Measurement Prompts"/>
|
<separator string="Measurement Prompts"/>
|
||||||
<field name="quick_look_prompt_ids" nolabel="1" readonly="1">
|
<field name="quick_look_prompt_ids" nolabel="1" readonly="1">
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<odoo>
|
|
||||||
<record id="view_res_config_settings_jobs" model="ir.ui.view">
|
|
||||||
<field name="name">res.config.settings.fp.jobs</field>
|
|
||||||
<field name="model">res.config.settings</field>
|
|
||||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//form" position="inside">
|
|
||||||
<app data-string="Fusion Plating Jobs" string="Fusion Plating Jobs" name="fusion_plating_jobs">
|
|
||||||
<block title="Native Job Migration" name="fp_jobs_migration">
|
|
||||||
<setting id="fp_use_native_jobs"
|
|
||||||
string="Use Native Plating Jobs"
|
|
||||||
help="When enabled, SO confirmation creates fp.job records instead of mrp.production. Phase-2 migration toggle.">
|
|
||||||
<field name="x_fc_use_native_jobs"/>
|
|
||||||
</setting>
|
|
||||||
</block>
|
|
||||||
</app>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
</odoo>
|
|
||||||
@@ -228,8 +228,8 @@ class FpJobStepInputWizardLine(models.TransientModel):
|
|||||||
_FP_INPUT_TYPE_SELECTION,
|
_FP_INPUT_TYPE_SELECTION,
|
||||||
string='Type',
|
string='Type',
|
||||||
)
|
)
|
||||||
target_min = fields.Float(string='Min')
|
target_min = fields.Float(string='Min', digits=(16, 6))
|
||||||
target_max = fields.Float(string='Max')
|
target_max = fields.Float(string='Max', digits=(16, 6))
|
||||||
target_unit = fields.Selection(
|
target_unit = fields.Selection(
|
||||||
FP_UOM_SELECTION,
|
FP_UOM_SELECTION,
|
||||||
string='Unit',
|
string='Unit',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Logistics',
|
'name': 'Fusion Plating — Logistics',
|
||||||
'version': '19.0.3.3.0',
|
'version': '19.0.3.5.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': (
|
'summary': (
|
||||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||||
|
|||||||
@@ -183,7 +183,60 @@ class FpDelivery(models.Model):
|
|||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Actions
|
# Actions
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
def _fp_check_account_hold(self, action_label):
|
||||||
|
"""Block shipping when the customer is on account hold.
|
||||||
|
|
||||||
|
Enforces the third leg of the SO banner promise ("SO confirmation,
|
||||||
|
invoicing AND SHIPPING are blocked"). Resolved through
|
||||||
|
``commercial_partner_id`` so a hold on the parent company applies
|
||||||
|
even when the delivery is addressed to a child contact.
|
||||||
|
|
||||||
|
Manager bypass: ``fp_skip_account_hold=True`` in context (matches
|
||||||
|
the pattern used in fp_direct_order_wizard and the SO action_confirm
|
||||||
|
manager-override). Non-managers can't bypass.
|
||||||
|
|
||||||
|
``getattr`` is defensive — the hold field lives in
|
||||||
|
``fusion_plating_invoicing``; this module doesn't dep on it.
|
||||||
|
"""
|
||||||
|
for rec in self:
|
||||||
|
partner = rec.partner_id.commercial_partner_id
|
||||||
|
if not getattr(partner, 'x_fc_account_hold', False):
|
||||||
|
continue
|
||||||
|
if self.env.context.get('fp_skip_account_hold'):
|
||||||
|
rec.message_post(body=_(
|
||||||
|
'Account-hold check bypassed via context flag for '
|
||||||
|
'%(action)s. Customer "%(name)s" is on hold (reason: '
|
||||||
|
'%(reason)s).'
|
||||||
|
) % {
|
||||||
|
'action': action_label,
|
||||||
|
'name': partner.name,
|
||||||
|
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'N/A',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
|
||||||
|
if not is_manager:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot %(action)s delivery "%(name)s" — customer "%(partner)s" '
|
||||||
|
'is on account hold.\n'
|
||||||
|
'Reason: %(reason)s\n\n'
|
||||||
|
'Contact a manager to override.'
|
||||||
|
) % {
|
||||||
|
'action': action_label,
|
||||||
|
'name': rec.name or rec.display_name,
|
||||||
|
'partner': partner.name,
|
||||||
|
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'No reason specified',
|
||||||
|
})
|
||||||
|
rec.message_post(body=_(
|
||||||
|
'Warning: Customer "%(name)s" is on account hold (reason: '
|
||||||
|
'%(reason)s). Delivery %(action)s by manager override.'
|
||||||
|
) % {
|
||||||
|
'name': partner.name,
|
||||||
|
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'N/A',
|
||||||
|
'action': action_label,
|
||||||
|
})
|
||||||
|
|
||||||
def action_schedule(self):
|
def action_schedule(self):
|
||||||
|
self._fp_check_account_hold(_('schedule'))
|
||||||
self.write({'state': 'scheduled'})
|
self.write({'state': 'scheduled'})
|
||||||
|
|
||||||
def action_start_route(self):
|
def action_start_route(self):
|
||||||
@@ -194,6 +247,7 @@ class FpDelivery(models.Model):
|
|||||||
is non-negotiable — without it the chain-of-custody hand-off
|
is non-negotiable — without it the chain-of-custody hand-off
|
||||||
has no signed party and the POD can't be linked to a person.
|
has no signed party and the POD can't be linked to a person.
|
||||||
"""
|
"""
|
||||||
|
self._fp_check_account_hold(_('dispatch'))
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.assigned_driver_id:
|
if not rec.assigned_driver_id:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Notifications',
|
'name': 'Fusion Plating — Notifications',
|
||||||
'version': '19.0.6.3.0',
|
'version': '19.0.6.4.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from . import res_partner
|
|||||||
from . import sale_order
|
from . import sale_order
|
||||||
from . import fp_receiving
|
from . import fp_receiving
|
||||||
from . import account_move
|
from . import account_move
|
||||||
|
from . import account_move_send
|
||||||
from . import account_payment
|
from . import account_payment
|
||||||
# Phase 5 (Sub 11) — mrp.production hook retired. The native equivalent
|
# Phase 5 (Sub 11) — mrp.production hook retired. The native equivalent
|
||||||
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
|
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# -*- 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, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveSend(models.AbstractModel):
|
||||||
|
_inherit = 'account.move.send'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_default_pdf_report_id(self, move):
|
||||||
|
"""Make the Fusion Plating invoice the official invoice PDF.
|
||||||
|
|
||||||
|
Odoo's Send wizard renders the chosen ``pdf_report`` as the
|
||||||
|
legal/audit-trail PDF (stored on ``move.invoice_pdf_report_id``)
|
||||||
|
and ALSO renders every extra report listed in the mail
|
||||||
|
template's ``report_template_ids`` minus the chosen pdf_report
|
||||||
|
and ``account.account_invoices`` (see
|
||||||
|
``_get_placeholder_mail_template_dynamic_attachments_data``).
|
||||||
|
|
||||||
|
Without this override the wizard picks ``account.account_invoices``
|
||||||
|
as the official PDF, so the email ships TWO invoices: the stock
|
||||||
|
Odoo one + our branded plating one (which is in our mail
|
||||||
|
template's ``report_template_ids``). Returning our report as the
|
||||||
|
default makes the set-difference cancel out and the customer
|
||||||
|
receives a single, branded invoice.
|
||||||
|
|
||||||
|
Partner-level and journal-level ``invoice_template_pdf_report_id``
|
||||||
|
overrides still win — admins can opt out per customer or journal.
|
||||||
|
"""
|
||||||
|
partner_default = move.commercial_partner_id.with_company(
|
||||||
|
move.company_id
|
||||||
|
).invoice_template_pdf_report_id
|
||||||
|
if partner_default:
|
||||||
|
return partner_default
|
||||||
|
journal_default = move.journal_id.with_company(
|
||||||
|
move.company_id
|
||||||
|
).invoice_template_pdf_report_id
|
||||||
|
if journal_default:
|
||||||
|
return journal_default
|
||||||
|
if move.move_type == 'out_invoice':
|
||||||
|
fp_report = self.env.ref(
|
||||||
|
'fusion_plating_reports.action_report_fp_invoice_portrait',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if fp_report and move._is_action_report_available(fp_report):
|
||||||
|
return fp_report
|
||||||
|
return super()._get_default_pdf_report_id(move)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.10.2.0',
|
'version': '19.0.10.3.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -399,6 +399,7 @@
|
|||||||
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
|
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
|
||||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||||
<field name="binding_type">report</field>
|
<field name="binding_type">report</field>
|
||||||
|
<field name="is_invoice_report" eval="True"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
|
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
|
||||||
|
|||||||
2
fusion_theme_switcher/__init__.py
Normal file
2
fusion_theme_switcher/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
33
fusion_theme_switcher/__manifest__.py
Normal file
33
fusion_theme_switcher/__manifest__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Fusion Theme Switcher",
|
||||||
|
"version": "19.0.1.0.1",
|
||||||
|
"category": "Productivity",
|
||||||
|
"summary": "Backend header toggle for Odoo Enterprise light and dark mode.",
|
||||||
|
"description": """
|
||||||
|
Fusion Theme Switcher
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Adds a compact backend header toggle so internal users can switch between
|
||||||
|
light mode and dark mode without opening My Preferences.
|
||||||
|
""",
|
||||||
|
"author": "Nexa Systems Inc.",
|
||||||
|
"website": "https://www.nexasystems.ca",
|
||||||
|
"license": "OPL-1",
|
||||||
|
"depends": [
|
||||||
|
"web_enterprise",
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"web.assets_backend": [
|
||||||
|
"fusion_theme_switcher/static/src/scss/theme_toggle_systray.scss",
|
||||||
|
"fusion_theme_switcher/static/src/xml/theme_toggle_systray.xml",
|
||||||
|
"fusion_theme_switcher/static/src/js/theme_toggle_systray.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"installable": True,
|
||||||
|
"auto_install": False,
|
||||||
|
"application": False,
|
||||||
|
}
|
||||||
81
fusion_theme_switcher/static/src/js/theme_toggle_systray.js
Normal file
81
fusion_theme_switcher/static/src/js/theme_toggle_systray.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { cookie } from "@web/core/browser/cookie";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { user } from "@web/core/user";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
const VALID_SCHEMES = ["light", "dark"];
|
||||||
|
|
||||||
|
function currentScheme() {
|
||||||
|
const cookieScheme = cookie.get("color_scheme");
|
||||||
|
if (VALID_SCHEMES.includes(cookieScheme)) {
|
||||||
|
return cookieScheme;
|
||||||
|
}
|
||||||
|
const userScheme = user.settings?.color_scheme;
|
||||||
|
return VALID_SCHEMES.includes(userScheme) ? userScheme : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FusionThemeToggleSystray extends Component {
|
||||||
|
static template = "fusion_theme_switcher.ThemeToggleSystray";
|
||||||
|
static props = [];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.state = useState({
|
||||||
|
scheme: currentScheme(),
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDark() {
|
||||||
|
return this.state.scheme === "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
get nextScheme() {
|
||||||
|
return this.isDark ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.nextScheme === "dark" ? _t("Switch to dark mode") : _t("Switch to light mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconClass() {
|
||||||
|
return this.isDark ? "fa fa-sun-o" : "fa fa-moon-o";
|
||||||
|
}
|
||||||
|
|
||||||
|
async onToggleTheme() {
|
||||||
|
if (this.state.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextScheme = this.nextScheme;
|
||||||
|
this.state.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await user.setUserSettings("color_scheme", nextScheme);
|
||||||
|
user.updateUserSettings("color_scheme", nextScheme);
|
||||||
|
cookie.set("color_scheme", nextScheme);
|
||||||
|
this.state.scheme = nextScheme;
|
||||||
|
browser.location.reload();
|
||||||
|
} catch {
|
||||||
|
this.state.loading = false;
|
||||||
|
this.notification.add(_t("Theme switch failed. Please try again."), {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fusionThemeToggleSystrayItem = {
|
||||||
|
Component: FusionThemeToggleSystray,
|
||||||
|
isDisplayed: () => Boolean(user.userId && user.isInternalUser && user.settings?.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("systray").add("fusion_theme_switcher.theme_toggle", fusionThemeToggleSystrayItem, {
|
||||||
|
sequence: 100,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.o_fts_theme_toggle {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #4b5563;
|
||||||
|
display: flex;
|
||||||
|
height: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 0;
|
||||||
|
align-self: center;
|
||||||
|
transition: background-color 120ms ease, color 120ms ease, opacity 120ms ease;
|
||||||
|
width: 24px;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
color: #111827;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px rgba(113, 75, 103, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fts_theme_toggle_dark {
|
||||||
|
color: #fbbf24;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fts_theme_toggle_loading {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_theme_switcher.ThemeToggleSystray">
|
||||||
|
<button type="button"
|
||||||
|
t-att-title="title"
|
||||||
|
t-att-aria-label="title"
|
||||||
|
t-att-disabled="state.loading"
|
||||||
|
t-att-class="{
|
||||||
|
'o_fts_theme_toggle': true,
|
||||||
|
'o_fts_theme_toggle_dark': isDark,
|
||||||
|
'o_fts_theme_toggle_loading': state.loading,
|
||||||
|
}"
|
||||||
|
t-on-click="onToggleTheme">
|
||||||
|
<i t-att-class="iconClass"/>
|
||||||
|
<span class="visually-hidden" t-esc="title"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
||||||
Reference in New Issue
Block a user