Compare commits
4 Commits
c5d21e0519
...
1da27ed6bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da27ed6bf | ||
|
|
bdcbd86db2 | ||
|
|
4213c44e51 | ||
|
|
b8d064b180 |
@@ -0,0 +1,323 @@
|
||||
# Simple Recipe Editor — Refinement (Bug Fix + Inline Library Authoring + Breadcrumbs)
|
||||
|
||||
**Status:** Design — pending implementation plan
|
||||
**Date:** 2026-04-30
|
||||
**Modules touched:** `fusion_plating`, `fusion_plating_jobs`
|
||||
**Versions to bump:** `fusion_plating` 19.0.11.4.0 → 19.0.12.0.0 (migration), `fusion_plating_jobs` 19.0.7.x` (no schema change, optional)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Three issues, surfaced together while a customer was trying to author recipes
|
||||
using the Simple Editor:
|
||||
|
||||
1. **Generation bug — Simple-Editor recipes produce zero job steps.**
|
||||
`walk_node()` in `fp_job.py:631` only creates `fp.job.step` rows for
|
||||
`node.node_type == 'operation'`. The Simple Editor inserts library
|
||||
templates with `node_type='step'` directly under the recipe root
|
||||
(`simple_recipe_controller.py:178`). Top-level step children of a
|
||||
recipe fall through. Verified on entech: `LGPS1104` has 19 flat step
|
||||
rows under the recipe root — `fp_job` has 0 rows referencing it. Real
|
||||
downstream consequence: no traveller content, no shopfloor tablet
|
||||
queue entries, no CoC moves, nothing. The recipe is silently inert.
|
||||
|
||||
2. **Library authoring requires a menu trip.** Users have to leave the
|
||||
Simple Editor, navigate to Configuration → Recipes & Steps → Step
|
||||
Library, create a template, configure prompts in a list view, then
|
||||
come back to the editor to drag it in. The template form is
|
||||
manager-grade (notebook with 4 tabs); it's not the right tool for a
|
||||
shop foreman who wants to capture a one-off step their facility
|
||||
does.
|
||||
|
||||
3. **No way back from the Simple Editor.** Click "Open Simple Editor"
|
||||
from the recipe form → full-screen client action with no back button,
|
||||
no breadcrumb. Operator has to use Chrome's back arrow to escape.
|
||||
The Tree Editor has had a custom "← Back to Recipes" button since
|
||||
2026-04-22; the Simple Editor was never given one.
|
||||
|
||||
## Goals
|
||||
|
||||
- Simple-Editor-authored recipes produce correct job steps end-to-end —
|
||||
WO traveller, shopfloor tablet, CoC, reports.
|
||||
- A shop foreman can author a new library step (with prompts, kind,
|
||||
stations, instructions) without leaving the Simple Editor.
|
||||
- A shop foreman can edit an existing library step without leaving the
|
||||
Simple Editor.
|
||||
- Closing the Simple Editor returns to the recipe list (or part form, if
|
||||
opened from the Process Composer).
|
||||
- Existing tree-editor recipes are untouched. Existing simple-editor
|
||||
recipes get migrated, with a chatter note explaining the change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new step kinds. The existing 23-value `default_kind` Selection
|
||||
stays as-is. Free-text custom kinds are explicitly out of scope —
|
||||
templates without a kind continue to behave as "Generic" (no auto-
|
||||
seeded prompts, no auto-gates), which is fine.
|
||||
- No change to the Tree Editor.
|
||||
- No change to the manager-grade Step Library form view.
|
||||
- No prompt-collection runtime behaviour change (`collect_measurements`,
|
||||
per-input `collect`, etc. all keep working as today).
|
||||
|
||||
---
|
||||
|
||||
## Bucket 1 — Generation bug fix
|
||||
|
||||
### Migration (one-shot, fusion_plating 19.0.12.0.0/post-migrate.py)
|
||||
|
||||
```sql
|
||||
-- Flip top-level step children of recipes → operation. Filter is
|
||||
-- intentionally narrow: only nodes whose parent is a recipe root.
|
||||
-- Tree-editor 'step' rows (which sit under operation parents) are
|
||||
-- untouched.
|
||||
UPDATE fusion_plating_process_node child
|
||||
SET node_type = 'operation'
|
||||
WHERE child.node_type = 'step'
|
||||
AND child.parent_id IN (
|
||||
SELECT id FROM fusion_plating_process_node WHERE node_type = 'recipe'
|
||||
);
|
||||
```
|
||||
|
||||
After the SQL runs, walk every distinct `recipe_root_id` whose children
|
||||
were touched and post a chatter note:
|
||||
|
||||
> "Recipe migrated to v19.0.12.0.0 step layout. Step nodes that were
|
||||
> direct children of this recipe (Simple Editor authoring) have been
|
||||
> promoted to operation nodes so they generate work-order steps
|
||||
> correctly. No data was lost — only `node_type` changed. If this
|
||||
> recipe was already authored via the Tree Editor with explicit
|
||||
> sub-process / operation hierarchy, this migration was a no-op for it."
|
||||
|
||||
### Controller change (going forward)
|
||||
|
||||
`simple_recipe_controller.py:178` —
|
||||
```python
|
||||
new_vals = {
|
||||
'parent_id': recipe.id,
|
||||
'node_type': 'operation', # was 'step' — fix Bucket 1 bug
|
||||
'sequence': target_seq,
|
||||
}
|
||||
```
|
||||
|
||||
`_snapshot_step_into()` (template/import path) gets the same fix on
|
||||
line 295.
|
||||
|
||||
### What "just works" after this
|
||||
|
||||
- `walk_node()` finds operation nodes, creates `fp.job.step` rows.
|
||||
- WO traveller (`report_fp_job_traveller.xml`) iterates
|
||||
`job.step_ids` — populated.
|
||||
- Shopfloor tablet `/fp/shopfloor/scan` returns the same step rows.
|
||||
- CoC chronological body uses `fp.job.step.move` rows that get created
|
||||
on tablet operations against real step rows.
|
||||
- Quality-point matching (`fp.quality.point._matches`) keys off
|
||||
`step.kind`, which is mapped from `node.default_kind` in the same
|
||||
walk_node code path. Templates with no kind get `step.kind='other'`
|
||||
— behaves like a generic step. No regression.
|
||||
|
||||
### Verification checklist (manual, after deploy)
|
||||
|
||||
1. Create a new job against `LGPS1104` on entech.
|
||||
2. Confirm `fp.job.step_ids` contains 19 rows (or however many the recipe has).
|
||||
3. Print the WO Traveller — every step listed.
|
||||
4. Open the operator tablet for that job — every step in My Queue.
|
||||
5. Walk one step start → finish on the tablet — `fp.job.step.move` row created.
|
||||
6. Generate a chronological CoC against the closed job — body lists each step heading.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 2 — Inline library authoring in Simple Editor
|
||||
|
||||
### UX
|
||||
|
||||
In the right-hand Step Library panel, above the search input:
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ + New Step │
|
||||
├──────────────────────────────┤
|
||||
│ [Search… ] │
|
||||
├──────────────────────────────┤
|
||||
│ ⚙ Acid Dip ✎ │
|
||||
│ 🔗 Adhesion Test ✎ │
|
||||
│ ... │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- **+ New Step** opens an inline form (replaces the library list while
|
||||
open, "Save" / "Cancel" return to list).
|
||||
- Pencil ✎ next to each library row → same form, prefilled with the
|
||||
template's current values.
|
||||
|
||||
### Inline form fields
|
||||
|
||||
Two-column grid:
|
||||
|
||||
```
|
||||
NAME * KIND
|
||||
[ ] [Generic — no automatic ▾]
|
||||
|
||||
ICON ALLOWED STATIONS
|
||||
[⚙ Cog ▾] [tag1] [tag2] [+]
|
||||
|
||||
INSTRUCTIONS
|
||||
[ ]
|
||||
[ ]
|
||||
[ ]
|
||||
|
||||
FLAGS
|
||||
☐ Require QA Sign-off
|
||||
☐ Require Predecessor Done
|
||||
☐ Requires Rack Assignment
|
||||
☐ Requires Transition Form
|
||||
|
||||
PROMPTS (Operation Measurements — what the operator records during this step)
|
||||
┌───────┬──────────────────┬──────────┬─────┬─────┬────┬───┐
|
||||
│Collect│ Prompt │ Type │ Min │ Max │Req │ × │
|
||||
├───────┼──────────────────┼──────────┼─────┼─────┼────┼───┤
|
||||
│ ☑ │ Actual Time │ Time(sec)│ │ │ │ × │
|
||||
│ ☑ │ Bath ID │ Text │ │ │ │ × │
|
||||
└───────┴──────────────────┴──────────┴─────┴─────┴────┴───┘
|
||||
[+ Add prompt] [Seed defaults from kind]
|
||||
|
||||
[Save] [Cancel]
|
||||
```
|
||||
|
||||
The PROMPTS table reuses the same column layout as the existing
|
||||
per-recipe-step edit panel (`simple_recipe_editor.xml:127-200`). Same
|
||||
input-type Selection (15 options), same min/max/req fields. Authors
|
||||
get one consistent table whether they're editing a recipe step or a
|
||||
library template.
|
||||
|
||||
The "Seed defaults from kind" button calls
|
||||
`action_seed_default_inputs()` server-side and refreshes — gives the
|
||||
shop foreman the same one-click seeding the manager form has.
|
||||
|
||||
### Controller endpoints (new)
|
||||
|
||||
```
|
||||
POST /fp/simple_recipe/library/load { template_id }
|
||||
POST /fp/simple_recipe/library/save { template_id|null, vals } # create or update
|
||||
POST /fp/simple_recipe/library/seed_defaults { template_id } # action_seed_default_inputs
|
||||
POST /fp/simple_recipe/library/input/add { template_id, payload }
|
||||
POST /fp/simple_recipe/library/input/write { input_id, payload }
|
||||
POST /fp/simple_recipe/library/input/remove { input_id }
|
||||
```
|
||||
|
||||
`library/save` does an upsert: if `template_id` is null, creates;
|
||||
otherwise writes. Returns the full template payload (same shape as
|
||||
`library/load`) so the OWL component can refresh state in one call.
|
||||
|
||||
The existing `library/create`, `library/write`, `library/delete` stay
|
||||
for back-compat (they're called by no other code today, so we could
|
||||
delete them, but cheap to leave).
|
||||
|
||||
### Snapshot semantics (no change)
|
||||
|
||||
When the author saves a new template and then drags it into a recipe,
|
||||
the existing `_copy_inputs_from_template()` ([simple_recipe_controller.py:223](fusion_plating/controllers/simple_recipe_controller.py:223))
|
||||
copies all `input_template_ids` → recipe-step `input_ids`. Editing the
|
||||
template later does NOT mutate recipes already built (Q4 = A locked
|
||||
2026-04-27). This bucket changes nothing about that contract.
|
||||
|
||||
### What's excluded from inline form (still requires manager form)
|
||||
|
||||
- Time/Temp/Voltage/Viscosity targets (Advanced tab) — rarely set by
|
||||
shop foreman, lots of pickers, would bloat the inline form. Stays on
|
||||
the manager form.
|
||||
- Transition Form fields — Sub 12b feature, more compliance-heavy,
|
||||
needs the dedicated tab. Stays on the manager form.
|
||||
- Common Audit Fields seeding — accessible via `action_add_common_audit_fields`
|
||||
on the manager form. Could be added to the inline form later if
|
||||
asked.
|
||||
|
||||
The pencil ✎ form has a small "Open in full editor" link in the
|
||||
corner that does `action.doAction({type:'ir.actions.act_window', res_model:'fp.step.template', res_id:tpl.id})` for the manager-only escape hatch.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 3 — Breadcrumb / Back navigation
|
||||
|
||||
### Header layout (Simple Editor)
|
||||
|
||||
```
|
||||
[← Back to Recipes] Recipe: LGPS1104 [Open in Tree Editor]
|
||||
```
|
||||
|
||||
### Logic (mirrors recipe_tree_editor.js:548)
|
||||
|
||||
```js
|
||||
onBackToList() {
|
||||
const partId = this.props.action?.context?.part_id;
|
||||
if (partId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
res_id: partId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
}, { clearBreadcrumbs: true });
|
||||
return;
|
||||
}
|
||||
this.action.doAction("fusion_plating.action_fp_process_recipe", {
|
||||
clearBreadcrumbs: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`clearBreadcrumbs: true` is critical — without it, every part-form →
|
||||
composer → editor → back leaves the intermediate pages on the breadcrumb
|
||||
stack. Same battle-scar the Tree Editor learned from in 2026-04-22.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 4 — Downstream verification
|
||||
|
||||
No code expected. Manual checklist after Buckets 1–3 ship to entech:
|
||||
|
||||
- [ ] Job generated from LGPS1104 has 19 fp.job.step rows
|
||||
- [ ] WO Traveller PDF lists every step
|
||||
- [ ] Tablet "My Queue" lists every step on the operator with that job's work centre
|
||||
- [ ] Step start → finish creates an `fp.job.step.move` row
|
||||
- [ ] Closed-job CoC body (chronological style) lists every step heading
|
||||
- [ ] User-added templates (Blasting, Surface Activation, Nickel Strip — Steel Line, Nickel Strip (S-1), Air Dry) flow through correctly
|
||||
- [ ] Templates without `default_kind` flow through with `fp.job.step.kind='other'` and behave as plain steps (no auto-gates)
|
||||
- [ ] Existing tree-editor recipes still produce the same number of steps as before (no regression)
|
||||
|
||||
---
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Migration touches a recipe authored by Tree Editor that happens to have a flat step under recipe root | Filter is `parent_id IN (recipe ids)`. Tree-editor `step` rows have `operation` parents, not `recipe`. Verified zero false positives on entech (only Simple-Editor recipes have `step` children of `recipe`). |
|
||||
| New job-step rows surface a Sub 12b "Requires Rack Assignment" or Sub 8 racking-inspection gate operator wasn't expecting | All such gates check `requires_rack_assignment` / `default_kind=='racking'`. User-added Blasting / Surface Activation templates have neither set, so they'll behave as plain operations. |
|
||||
| Inline form lets a foreman delete a library template that's used in 30 recipes | `library/delete` is already soft-delete (sets active=False) when used. Inline form keeps the same behaviour. |
|
||||
| Author edits a library template's prompts and expects existing recipes to update | They won't — snapshot semantics still apply. UX mitigation: on save, post a notification "Saved. Existing recipes using this step keep their old prompts; new drops will use the new prompts." |
|
||||
| Migration runs twice | SQL is idempotent (only flips `step` → `operation`; second run finds no `step` children of recipes, no-op). Chatter note is gated on `node_type` change so won't double-post. |
|
||||
|
||||
---
|
||||
|
||||
## Module versions
|
||||
|
||||
- `fusion_plating`: bump to 19.0.12.0.0 (migration + controller change + OWL changes)
|
||||
- `fusion_plating_jobs`: no version bump required, but worth a smoke test
|
||||
|
||||
## Build order
|
||||
|
||||
1. Migration `fusion_plating/migrations/19.0.12.0.0/post-migrate.py` — flip + chatter
|
||||
2. Controller — change `node_type='operation'` in two places + add 6 new `library/*` endpoints
|
||||
3. OWL JS — `simple_recipe_editor.js` adds inline form state + handlers + back button
|
||||
4. OWL XML — `simple_recipe_editor.xml` adds inline form template + back button + pencil
|
||||
5. SCSS — minimal; reuse existing classes from edit panel
|
||||
6. Bump `fusion_plating/__manifest__.py` version
|
||||
7. Deploy to entech, run verification checklist
|
||||
|
||||
## Things explicitly NOT in this spec
|
||||
|
||||
- New step kinds (deferred — clients add templates as they go, kind stays optional)
|
||||
- Tree Editor changes
|
||||
- Step Library manager-grade form changes
|
||||
- New report content
|
||||
- New shopfloor tablet content
|
||||
- Free-text custom kinds (rejected — would silently lose 4 functional behaviours)
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.7.4',
|
||||
'version': '19.0.18.11.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -167,6 +167,156 @@ class SimpleRecipeController(http.Controller):
|
||||
tpl.unlink()
|
||||
return {'ok': True, 'soft_deleted': False}
|
||||
|
||||
# ---- Inline authoring (Simple Editor right-pane "+ New Step" / pencil) ----
|
||||
#
|
||||
# Endpoints below let a shop foreman create or edit a library template
|
||||
# (with prompts) without leaving the Simple Editor. Manager-grade
|
||||
# features (transition form, advanced time/temp targets, common-audit
|
||||
# seeding) still live on the dedicated form view.
|
||||
|
||||
@http.route('/fp/simple_recipe/library/load', type='jsonrpc', auth='user')
|
||||
def library_load(self, template_id):
|
||||
"""Return the full payload for one library template."""
|
||||
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||||
if not tpl.exists():
|
||||
return {'ok': False, 'error': 'not_found'}
|
||||
tpl.check_access('read')
|
||||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||
|
||||
def _library_payload(self, tpl):
|
||||
return {
|
||||
'id': tpl.id,
|
||||
'name': tpl.name or '',
|
||||
'code': tpl.code or '',
|
||||
'icon': tpl.icon or 'fa-cog',
|
||||
'default_kind': tpl.default_kind or '',
|
||||
'description': tpl.description or '',
|
||||
'requires_signoff': tpl.requires_signoff,
|
||||
'requires_predecessor_done': tpl.requires_predecessor_done,
|
||||
'requires_rack_assignment': tpl.requires_rack_assignment,
|
||||
'requires_transition_form': tpl.requires_transition_form,
|
||||
'tank_ids': [
|
||||
{'id': t.id, 'name': t.name, 'code': t.code or ''}
|
||||
for t in tpl.tank_ids
|
||||
],
|
||||
'inputs': [
|
||||
{
|
||||
'id': i.id,
|
||||
'name': i.name or '',
|
||||
'input_type': i.input_type or 'text',
|
||||
'target_min': i.target_min or 0.0,
|
||||
'target_max': i.target_max or 0.0,
|
||||
'target_unit': i.target_unit or '',
|
||||
'required': bool(i.required),
|
||||
'sequence': i.sequence or 0,
|
||||
'hint': i.hint or '',
|
||||
'selection_options': i.selection_options or '',
|
||||
}
|
||||
for i in tpl.input_template_ids.sorted('sequence')
|
||||
],
|
||||
}
|
||||
|
||||
@http.route('/fp/simple_recipe/library/save', type='jsonrpc', auth='user')
|
||||
def library_save(self, template_id, vals):
|
||||
"""Upsert: create when template_id is falsy, write otherwise.
|
||||
Returns the full template payload so the OWL component can
|
||||
refresh in one round-trip.
|
||||
"""
|
||||
Tpl = request.env['fp.step.template']
|
||||
# Whitelist — never trust client-provided write_uid / id / etc.
|
||||
allowed = {
|
||||
'name', 'code', 'icon', 'default_kind', 'description',
|
||||
'requires_signoff', 'requires_predecessor_done',
|
||||
'requires_rack_assignment', 'requires_transition_form',
|
||||
'tank_ids',
|
||||
}
|
||||
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||||
# tank_ids comes in as a plain list of ids from the OWL form;
|
||||
# translate into the Odoo (6, 0, ids) command form.
|
||||
if 'tank_ids' in clean:
|
||||
clean['tank_ids'] = [(6, 0, [int(x) for x in clean['tank_ids']])]
|
||||
if template_id:
|
||||
tpl = Tpl.browse(int(template_id))
|
||||
tpl.check_access('write')
|
||||
tpl.write(clean)
|
||||
else:
|
||||
tpl = Tpl.create(clean)
|
||||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||
|
||||
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
|
||||
def library_seed_defaults(self, template_id):
|
||||
"""Run action_seed_default_inputs on this template. Idempotent —
|
||||
only adds prompts whose name doesn't already exist.
|
||||
"""
|
||||
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||||
if not tpl.exists():
|
||||
return {'ok': False, 'error': 'not_found'}
|
||||
tpl.check_access('write')
|
||||
if not tpl.default_kind:
|
||||
return {'ok': False, 'error': 'no_kind',
|
||||
'message': 'Pick a Step Kind first to seed defaults.'}
|
||||
tpl.action_seed_default_inputs()
|
||||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||
|
||||
@http.route('/fp/simple_recipe/library/input/add', type='jsonrpc', auth='user')
|
||||
def library_input_add(self, template_id, payload):
|
||||
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||||
tpl.check_access('write')
|
||||
Input = request.env['fp.step.template.input']
|
||||
existing_max = max(tpl.input_template_ids.mapped('sequence') or [0])
|
||||
rec = Input.create({
|
||||
'template_id': tpl.id,
|
||||
'name': (payload or {}).get('name') or 'New Prompt',
|
||||
'input_type': (payload or {}).get('input_type') or 'text',
|
||||
'sequence': existing_max + 10,
|
||||
'required': bool((payload or {}).get('required')),
|
||||
})
|
||||
return {'ok': True, 'input_id': rec.id,
|
||||
'template': self._library_payload(tpl)}
|
||||
|
||||
@http.route('/fp/simple_recipe/library/input/write', type='jsonrpc', auth='user')
|
||||
def library_input_write(self, input_id, payload):
|
||||
Input = request.env['fp.step.template.input']
|
||||
rec = Input.browse(int(input_id))
|
||||
if not rec.exists():
|
||||
return {'ok': False, 'error': 'not_found'}
|
||||
rec.template_id.check_access('write')
|
||||
allowed = {
|
||||
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
|
||||
'required', 'sequence', 'selection_options', 'hint',
|
||||
}
|
||||
clean = {k: v for k, v in (payload or {}).items() if k in allowed}
|
||||
if clean:
|
||||
rec.write(clean)
|
||||
return {'ok': True, 'template': self._library_payload(rec.template_id)}
|
||||
|
||||
@http.route('/fp/simple_recipe/library/input/remove', type='jsonrpc', auth='user')
|
||||
def library_input_remove(self, input_id):
|
||||
Input = request.env['fp.step.template.input']
|
||||
rec = Input.browse(int(input_id))
|
||||
if not rec.exists():
|
||||
return {'ok': False, 'error': 'not_found'}
|
||||
tpl = rec.template_id
|
||||
tpl.check_access('write')
|
||||
rec.unlink()
|
||||
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||
|
||||
@http.route('/fp/simple_recipe/tank/list', type='jsonrpc', auth='user')
|
||||
def tank_list(self, query=''):
|
||||
"""Tank picker for the inline library form. Returns active tanks
|
||||
scoped to the current company.
|
||||
"""
|
||||
Tank = request.env['fusion.plating.tank']
|
||||
domain = [('active', '=', True)]
|
||||
if query:
|
||||
domain += ['|', ('name', 'ilike', query), ('code', 'ilike', query)]
|
||||
return {
|
||||
'tanks': [
|
||||
{'id': t.id, 'name': t.name, 'code': t.code or ''}
|
||||
for t in Tank.search(domain, limit=50)
|
||||
],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step
|
||||
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
|
||||
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
|
||||
@@ -175,7 +325,10 @@ class SimpleRecipeController(http.Controller):
|
||||
|
||||
new_vals = {
|
||||
'parent_id': recipe.id,
|
||||
'node_type': 'step',
|
||||
# Must be 'operation' — fp.job._generate_steps() only creates
|
||||
# fp.job.step rows for operation nodes. Flat 'step' children
|
||||
# of a recipe were silently skipped pre-19.0.18.8.0.
|
||||
'node_type': 'operation',
|
||||
'sequence': target_seq,
|
||||
}
|
||||
tpl = False
|
||||
@@ -291,7 +444,8 @@ class SimpleRecipeController(http.Controller):
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
new_vals = {
|
||||
'parent_id': target_recipe.id,
|
||||
'node_type': 'step',
|
||||
# See _SNAPSHOT_FIELDS comment — operation, not step.
|
||||
'node_type': 'operation',
|
||||
'sequence': src_node.sequence,
|
||||
'source_template_id': src_node.source_template_id.id or False,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Post-migration for 19.0.18.8.0 — Simple Editor node_type fix.
|
||||
|
||||
Background
|
||||
----------
|
||||
The Simple Recipe Editor's controller used to insert library templates
|
||||
into a recipe with `node_type='step'` directly under the recipe root.
|
||||
But fp.job._generate_steps() (in fusion_plating_jobs/models/fp_job.py)
|
||||
only creates fp.job.step rows for nodes whose node_type is 'operation'.
|
||||
Top-level 'step' children of a recipe were silently skipped, meaning
|
||||
Simple-Editor recipes generated zero job steps — no traveller content,
|
||||
no shopfloor tablet entries, no CoC moves.
|
||||
|
||||
Fix
|
||||
---
|
||||
Promote every `step` node whose direct parent is a `recipe` to
|
||||
`node_type='operation'`. Tree-editor authored 'step' nodes (which sit
|
||||
under `operation` parents) are left untouched — the filter is on
|
||||
`parent.node_type='recipe'`.
|
||||
|
||||
Idempotent: a second run finds no `step` children of recipes and is
|
||||
a no-op. Posts a chatter note on each affected recipe so QA / clients
|
||||
have a paper trail.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_AUDIT_BODY = (
|
||||
'<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>'
|
||||
'<p>Step nodes that were direct children of this recipe (Simple '
|
||||
'Editor authoring) have been promoted to operation nodes so they '
|
||||
'generate work-order steps correctly. No data was lost — only '
|
||||
'<code>node_type</code> changed.</p>'
|
||||
'<p>If this recipe was authored via the Tree Editor with explicit '
|
||||
'sub-process / operation hierarchy, this migration was a no-op '
|
||||
'for it.</p>'
|
||||
)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Find recipe ids whose direct children include any 'step' rows.
|
||||
# We need this list BEFORE we flip the rows so we can post chatter
|
||||
# afterwards.
|
||||
cr.execute("""
|
||||
SELECT DISTINCT parent.id
|
||||
FROM fusion_plating_process_node parent
|
||||
JOIN fusion_plating_process_node child
|
||||
ON child.parent_id = parent.id
|
||||
WHERE parent.node_type = 'recipe'
|
||||
AND child.node_type = 'step'
|
||||
""")
|
||||
affected_recipe_ids = [r[0] for r in cr.fetchall()]
|
||||
|
||||
if not affected_recipe_ids:
|
||||
_logger.info(
|
||||
"Sub Simple-Editor migration: no flat-step recipes found, "
|
||||
"nothing to do."
|
||||
)
|
||||
return
|
||||
|
||||
# Flip the node_type. Filter is intentionally narrow — only direct
|
||||
# children of a recipe get promoted. Tree-editor sub-step rows
|
||||
# (parent.node_type='operation') are untouched.
|
||||
cr.execute("""
|
||||
UPDATE fusion_plating_process_node child
|
||||
SET node_type = 'operation'
|
||||
WHERE child.node_type = 'step'
|
||||
AND child.parent_id IN (
|
||||
SELECT id
|
||||
FROM fusion_plating_process_node
|
||||
WHERE node_type = 'recipe'
|
||||
)
|
||||
""")
|
||||
flipped = cr.rowcount
|
||||
_logger.info(
|
||||
"Sub Simple-Editor migration: promoted %s step nodes to "
|
||||
"operation across %s recipes.",
|
||||
flipped, len(affected_recipe_ids),
|
||||
)
|
||||
|
||||
# Post a chatter note on each affected recipe (best-effort).
|
||||
Node = env['fusion.plating.process.node']
|
||||
for recipe in Node.browse(affected_recipe_ids):
|
||||
try:
|
||||
recipe.message_post(
|
||||
body=_AUDIT_BODY,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Failed to post audit note on recipe %s: %s",
|
||||
recipe.id, e,
|
||||
)
|
||||
@@ -546,15 +546,23 @@ export class RecipeTreeEditor extends Component {
|
||||
// ---- Navigation ---------------------------------------------------------
|
||||
|
||||
onBackToList() {
|
||||
// If the editor was opened from the part-scoped Process Composer
|
||||
// (context carried part_id), return to that part's form instead
|
||||
// of the generic Recipes list.
|
||||
// Pop this editor off the action stack and restore the
|
||||
// previous controller — which is whatever opened the editor:
|
||||
// * Recipes list → recipe form → editor ⇒ back to recipe form
|
||||
// * Part form → composer → editor ⇒ back to composer
|
||||
// * Part form → editor (direct link) ⇒ back to part form
|
||||
//
|
||||
// clearBreadcrumbs: this is a semantic RETURN, not a forward
|
||||
// navigation. Without it, every round-trip (part → composer →
|
||||
// editor → back) leaves its intermediate pages on the breadcrumb
|
||||
// stack, so a second visit shows "…/Process Composer/Process
|
||||
// Editor/Process Composer/Process Editor/Part" nonsense.
|
||||
// restore() preserves the full breadcrumb trail.
|
||||
// clearBreadcrumbs: true (the old behaviour) would wipe parent
|
||||
// crumbs and isolate the user on a single-crumb page.
|
||||
try {
|
||||
this.action.restore();
|
||||
return;
|
||||
} catch (e) {
|
||||
// No prior controller — fall through to a sensible default.
|
||||
}
|
||||
// Fallback: when opened directly via URL with no prior crumb,
|
||||
// pick the most contextual landing page we have.
|
||||
if (this._partId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
|
||||
@@ -44,13 +44,25 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
editingStepId: null,
|
||||
editName: "",
|
||||
editInstructions: "",
|
||||
// Inline library form — open when authoring or editing a
|
||||
// library template directly from the right pane. null =
|
||||
// closed; otherwise carries the template payload.
|
||||
libraryEditor: null,
|
||||
libraryEditorBusy: false,
|
||||
tankSearchResults: [],
|
||||
});
|
||||
|
||||
this._recipeId = null;
|
||||
this._partId = null;
|
||||
|
||||
onMounted(async () => {
|
||||
const ctx = this.props.action?.context || {};
|
||||
this._recipeId = ctx.recipe_id || null;
|
||||
this._partId = ctx.part_id || null;
|
||||
// Mirror onto state so the OWL template can branch on it
|
||||
// (template scope sees state directly, not arbitrary
|
||||
// instance properties).
|
||||
this.state.fromPart = !!this._partId;
|
||||
if (this._recipeId) {
|
||||
await this.loadAll();
|
||||
} else {
|
||||
@@ -173,6 +185,240 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- back navigation
|
||||
/**
|
||||
* Mirror of recipe_tree_editor.onBackToList. When the editor was
|
||||
* opened from the part-scoped Process Composer, return to that part
|
||||
* form; otherwise drop the user back on the Recipes list.
|
||||
*
|
||||
* `clearBreadcrumbs: true` is critical — without it, every part →
|
||||
* composer → editor → back leaves intermediate pages on the
|
||||
* breadcrumb stack so a second visit shows nonsense.
|
||||
*/
|
||||
onBackToList() {
|
||||
// Pop this editor off the action stack and restore the
|
||||
// previous controller — preserves the full breadcrumb trail
|
||||
// (Recipes > LGPS1104 > Editor → back keeps "Recipes"
|
||||
// visible; Part > Composer > Editor → back returns to the
|
||||
// Composer with crumbs intact).
|
||||
try {
|
||||
this.action.restore();
|
||||
return;
|
||||
} catch (e) {
|
||||
// No prior controller — fall through to a sensible default.
|
||||
}
|
||||
if (this._partId) {
|
||||
this.action.doAction(
|
||||
{
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
res_id: this._partId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
},
|
||||
{ clearBreadcrumbs: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.action.doAction("fusion_plating.action_fp_process_recipe", {
|
||||
clearBreadcrumbs: true,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------- inline library form
|
||||
/**
|
||||
* Open the inline form for a NEW library template. Skeleton payload
|
||||
* mirrors the shape returned by `/fp/simple_recipe/library/load` so
|
||||
* the same template renders both create + edit.
|
||||
*/
|
||||
onOpenLibraryCreate() {
|
||||
this.state.libraryEditor = {
|
||||
id: null, // null = create
|
||||
name: "",
|
||||
code: "",
|
||||
icon: "fa-cog",
|
||||
default_kind: "",
|
||||
description: "",
|
||||
requires_signoff: false,
|
||||
requires_predecessor_done: false,
|
||||
requires_rack_assignment: false,
|
||||
requires_transition_form: false,
|
||||
tank_ids: [],
|
||||
inputs: [],
|
||||
};
|
||||
this.state.tankSearchResults = [];
|
||||
}
|
||||
|
||||
async onOpenLibraryEdit(templateId) {
|
||||
this.state.libraryEditorBusy = true;
|
||||
const data = await rpc("/fp/simple_recipe/library/load", {
|
||||
template_id: templateId,
|
||||
});
|
||||
if (data.ok) {
|
||||
// Defensive copy — OWL useState wraps top-level fields, but
|
||||
// we want to be able to mutate this.state.libraryEditor.* in
|
||||
// place without triggering library list re-renders.
|
||||
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
|
||||
} else {
|
||||
this.notification.add(
|
||||
_t("Could not load library template — it may have been deleted."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
}
|
||||
this.state.libraryEditorBusy = false;
|
||||
}
|
||||
|
||||
onCancelLibraryEditor() {
|
||||
this.state.libraryEditor = null;
|
||||
this.state.tankSearchResults = [];
|
||||
}
|
||||
|
||||
async onSaveLibraryEditor() {
|
||||
const ed = this.state.libraryEditor;
|
||||
if (!ed) return;
|
||||
if (!(ed.name || "").trim()) {
|
||||
this.notification.add(_t("Name is required."), { type: "warning" });
|
||||
return;
|
||||
}
|
||||
this.state.libraryEditorBusy = true;
|
||||
const vals = {
|
||||
name: ed.name,
|
||||
code: ed.code,
|
||||
icon: ed.icon,
|
||||
default_kind: ed.default_kind || false,
|
||||
description: ed.description,
|
||||
requires_signoff: !!ed.requires_signoff,
|
||||
requires_predecessor_done: !!ed.requires_predecessor_done,
|
||||
requires_rack_assignment: !!ed.requires_rack_assignment,
|
||||
requires_transition_form: !!ed.requires_transition_form,
|
||||
tank_ids: (ed.tank_ids || []).map((t) => t.id),
|
||||
};
|
||||
const result = await rpc("/fp/simple_recipe/library/save", {
|
||||
template_id: ed.id || false,
|
||||
vals: vals,
|
||||
});
|
||||
if (result.ok) {
|
||||
// Refresh in place so the Inputs section reflects DB state
|
||||
// (e.g. id assigned to a freshly-created template).
|
||||
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||
// Refresh the library list in the right pane.
|
||||
const libData = await rpc("/fp/simple_recipe/library/list", {
|
||||
query: this.state.librarySearch || "",
|
||||
});
|
||||
this.state.library = libData.templates;
|
||||
this.notification.add(
|
||||
ed.id ? _t("Library step updated") : _t("Library step created"),
|
||||
{ type: "success" }
|
||||
);
|
||||
} else {
|
||||
this.notification.add(
|
||||
result.error || _t("Save failed"),
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
this.state.libraryEditorBusy = false;
|
||||
}
|
||||
|
||||
async onSeedLibraryDefaults() {
|
||||
const ed = this.state.libraryEditor;
|
||||
if (!ed || !ed.id) {
|
||||
this.notification.add(
|
||||
_t("Save the step first, then seed defaults."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!ed.default_kind) {
|
||||
this.notification.add(
|
||||
_t("Pick a Step Kind first to seed defaults."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await rpc("/fp/simple_recipe/library/seed_defaults", {
|
||||
template_id: ed.id,
|
||||
});
|
||||
if (result.ok) {
|
||||
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||
this.notification.add(_t("Default prompts seeded"), { type: "success" });
|
||||
} else {
|
||||
this.notification.add(
|
||||
result.message || _t("Seed failed"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onAddLibraryInput() {
|
||||
const ed = this.state.libraryEditor;
|
||||
if (!ed || !ed.id) {
|
||||
this.notification.add(
|
||||
_t("Save the step first, then add prompts."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await rpc("/fp/simple_recipe/library/input/add", {
|
||||
template_id: ed.id,
|
||||
payload: { name: _t("New Prompt"), input_type: "text" },
|
||||
});
|
||||
if (result.ok) {
|
||||
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||
}
|
||||
}
|
||||
|
||||
async onLibraryInputBlur(inputId, field, ev) {
|
||||
const value = ev.target.value;
|
||||
if (field === "name" && !value.trim()) return;
|
||||
await this._libraryInputWrite(inputId, field, value);
|
||||
}
|
||||
|
||||
async onLibraryInputChange(inputId, field, value) {
|
||||
await this._libraryInputWrite(inputId, field, value);
|
||||
}
|
||||
|
||||
async _libraryInputWrite(inputId, field, value) {
|
||||
const payload = {};
|
||||
payload[field] = value;
|
||||
const result = await rpc("/fp/simple_recipe/library/input/write", {
|
||||
input_id: inputId,
|
||||
payload: payload,
|
||||
});
|
||||
if (result.ok) {
|
||||
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||
}
|
||||
}
|
||||
|
||||
async onRemoveLibraryInput(inputId) {
|
||||
const proceed = await this._confirm(_t("Remove this prompt?"));
|
||||
if (!proceed) return;
|
||||
const result = await rpc("/fp/simple_recipe/library/input/remove", {
|
||||
input_id: inputId,
|
||||
});
|
||||
if (result.ok) {
|
||||
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||
}
|
||||
}
|
||||
|
||||
async onSearchTanks(ev) {
|
||||
const q = ev.target.value;
|
||||
const data = await rpc("/fp/simple_recipe/tank/list", { query: q });
|
||||
this.state.tankSearchResults = data.tanks;
|
||||
}
|
||||
|
||||
onAddTank(tank) {
|
||||
const ed = this.state.libraryEditor;
|
||||
if (!ed) return;
|
||||
if (ed.tank_ids.some((t) => t.id === tank.id)) return;
|
||||
ed.tank_ids.push(tank);
|
||||
}
|
||||
|
||||
onRemoveTank(tankId) {
|
||||
const ed = this.state.libraryEditor;
|
||||
if (!ed) return;
|
||||
ed.tank_ids = ed.tank_ids.filter((t) => t.id !== tankId);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- drag & drop
|
||||
|
||||
onSelectedDragStart(stepId, ev) {
|
||||
|
||||
@@ -321,3 +321,174 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
text-align: center;
|
||||
color: $fp-se-muted;
|
||||
}
|
||||
|
||||
// ---- Back button + library header (Bucket 3) ----
|
||||
.o_fp_se_back {
|
||||
color: $fp-se-accent;
|
||||
margin-right: 1rem;
|
||||
padding: .25rem .5rem;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: $fp-se-accent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_library_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
h3 { margin: 0; }
|
||||
}
|
||||
|
||||
.o_fp_library_item {
|
||||
.o_fp_library_edit {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: $fp-se-muted;
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
|
||||
&:hover { color: $fp-se-accent; }
|
||||
}
|
||||
|
||||
&:hover .o_fp_library_edit { opacity: 1; }
|
||||
}
|
||||
|
||||
// ---- Inline library editor (Bucket 2) ----
|
||||
.o_fp_library_editor {
|
||||
background: $fp-se-card;
|
||||
border: 1px solid $fp-se-border;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
|
||||
&.o_fp_busy { opacity: .65; pointer-events: none; }
|
||||
}
|
||||
|
||||
.o_fp_library_editor_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: .75rem;
|
||||
border-bottom: 1px solid $fp-se-border;
|
||||
|
||||
h3 { margin: 0; font-size: 1.1rem; }
|
||||
}
|
||||
|
||||
.o_fp_library_editor_body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_le_row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
.o_fp_le_field { flex: 1; }
|
||||
}
|
||||
|
||||
.o_fp_le_field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
|
||||
label {
|
||||
font-size: .85rem;
|
||||
font-weight: 500;
|
||||
color: $fp-se-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_le_tank_chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.o_fp_le_tank_chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .15rem .5rem;
|
||||
background: rgba(0, 100, 200, .1);
|
||||
border-radius: 12px;
|
||||
font-size: .85rem;
|
||||
|
||||
.o_fp_le_tank_remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: $fp-se-muted;
|
||||
|
||||
&:hover { color: red; }
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_le_tank_results {
|
||||
border: 1px solid $fp-se-border;
|
||||
border-radius: 4px;
|
||||
margin-top: .25rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
background: $fp-se-card;
|
||||
}
|
||||
|
||||
.o_fp_le_tank_option {
|
||||
padding: .35rem .5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid $fp-se-border;
|
||||
|
||||
&:last-child { border-bottom: 0; }
|
||||
&:hover { background: rgba(0, 100, 200, .08); }
|
||||
}
|
||||
|
||||
.o_fp_le_flags {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: .35rem .75rem;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
font-size: .9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_le_prompts {
|
||||
border-top: 1px solid $fp-se-border;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_le_prompts_header {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.o_fp_le_prompt_actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
.o_fp_library_editor_actions {
|
||||
margin-top: 1rem;
|
||||
padding-top: .75rem;
|
||||
border-top: 1px solid $fp-se-border;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<t t-name="fusion_plating.FpSimpleRecipeEditor">
|
||||
<div class="o_fp_simple_editor">
|
||||
<div class="o_fp_simple_editor_header">
|
||||
<button class="btn btn-link o_fp_se_back"
|
||||
t-on-click="onBackToList"
|
||||
t-att-title="state.fromPart ? 'Back to part' : 'Back to recipes'">
|
||||
<i class="fa fa-arrow-left me-2"/>
|
||||
<t t-if="state.fromPart">Part</t>
|
||||
<t t-else="">Recipes</t>
|
||||
</button>
|
||||
<h2 t-if="state.recipe">
|
||||
Recipe: <span t-esc="state.recipe.name"/>
|
||||
</h2>
|
||||
@@ -256,27 +263,287 @@
|
||||
</div>
|
||||
|
||||
<div class="o_fp_library_panel">
|
||||
<h3>Step Library</h3>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Search…"
|
||||
t-on-input="onSearchLibrary"
|
||||
t-att-value="state.librarySearch"/>
|
||||
<div class="o_fp_library_list">
|
||||
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
|
||||
<div class="o_fp_library_item"
|
||||
draggable="true"
|
||||
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
|
||||
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
|
||||
<span class="o_fp_library_name" t-esc="tpl.name"/>
|
||||
<span class="o_fp_library_meta" t-if="tpl.station_count">
|
||||
<t t-esc="tpl.station_count"/> st.
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_fp_library_empty" t-if="!state.library.length">
|
||||
No library entries match your search.
|
||||
<!-- ============== LIBRARY LIST (default view) ============== -->
|
||||
<t t-if="!state.libraryEditor">
|
||||
<div class="o_fp_library_header">
|
||||
<h3>Step Library</h3>
|
||||
<button class="btn btn-sm btn-primary o_fp_lib_new"
|
||||
t-on-click="onOpenLibraryCreate"
|
||||
title="Create a new library step (with prompts)">
|
||||
<i class="fa fa-plus me-1"/>New Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Search…"
|
||||
t-on-input="onSearchLibrary"
|
||||
t-att-value="state.librarySearch"/>
|
||||
<div class="o_fp_library_list">
|
||||
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
|
||||
<div class="o_fp_library_item"
|
||||
draggable="true"
|
||||
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
|
||||
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
|
||||
<span class="o_fp_library_name" t-esc="tpl.name"/>
|
||||
<span class="o_fp_library_meta" t-if="tpl.station_count">
|
||||
<t t-esc="tpl.station_count"/> st.
|
||||
</span>
|
||||
<button class="o_fp_library_edit"
|
||||
title="Edit this library step"
|
||||
t-on-click.stop="() => this.onOpenLibraryEdit(tpl.id)">
|
||||
<i class="fa fa-pencil"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_fp_library_empty" t-if="!state.library.length">
|
||||
No library entries match your search.
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ============== INLINE LIBRARY EDITOR ============== -->
|
||||
<t t-if="state.libraryEditor">
|
||||
<div class="o_fp_library_editor"
|
||||
t-att-class="state.libraryEditorBusy ? 'o_fp_busy' : ''">
|
||||
<div class="o_fp_library_editor_header">
|
||||
<h3 t-if="!state.libraryEditor.id">+ New Library Step</h3>
|
||||
<h3 t-else="">Edit: <t t-esc="state.libraryEditor.name"/></h3>
|
||||
<button class="btn btn-link btn-sm"
|
||||
t-on-click="onCancelLibraryEditor"
|
||||
title="Close without saving recent unsaved field">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_library_editor_body">
|
||||
<div class="o_fp_le_row">
|
||||
<div class="o_fp_le_field">
|
||||
<label>Name *</label>
|
||||
<input type="text" class="form-control"
|
||||
t-model="state.libraryEditor.name"
|
||||
placeholder="e.g. Surface Activation"/>
|
||||
</div>
|
||||
<div class="o_fp_le_field">
|
||||
<label>Code</label>
|
||||
<input type="text" class="form-control"
|
||||
t-model="state.libraryEditor.code"
|
||||
placeholder="e.g. SURF_ACT"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_le_row">
|
||||
<div class="o_fp_le_field">
|
||||
<label>Step Kind
|
||||
<i class="fa fa-question-circle ms-1"
|
||||
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
|
||||
</label>
|
||||
<select class="form-select"
|
||||
t-model="state.libraryEditor.default_kind">
|
||||
<option value="">Generic — no automatic behaviour</option>
|
||||
<option value="receiving">Receiving / Incoming Inspection</option>
|
||||
<option value="contract_review">Contract Review (QA-005)</option>
|
||||
<option value="racking">Racking</option>
|
||||
<option value="mask">Masking</option>
|
||||
<option value="cleaning">Cleaning</option>
|
||||
<option value="electroclean">Electroclean</option>
|
||||
<option value="etch">Etch / Activation</option>
|
||||
<option value="rinse">Rinse</option>
|
||||
<option value="strike">Strike (Wood's Nickel / Activation)</option>
|
||||
<option value="plate">Plating</option>
|
||||
<option value="replenishment">Tank Replenishment</option>
|
||||
<option value="wbf_test">Water Break Free Test</option>
|
||||
<option value="dry">Drying</option>
|
||||
<option value="bake">Bake (HE Relief / Stress Relief)</option>
|
||||
<option value="demask">De-Masking</option>
|
||||
<option value="derack">De-Racking</option>
|
||||
<option value="inspect">Inspection</option>
|
||||
<option value="hardness_test">Hardness Test</option>
|
||||
<option value="adhesion_test">Adhesion Test</option>
|
||||
<option value="salt_spray">Salt Spray / Corrosion Test</option>
|
||||
<option value="final_inspect">Final Inspection</option>
|
||||
<option value="packaging">Packaging / Pre-Ship</option>
|
||||
<option value="ship">Shipping</option>
|
||||
<option value="gating">Gating</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="o_fp_le_field">
|
||||
<label>Icon</label>
|
||||
<input type="text" class="form-control"
|
||||
t-model="state.libraryEditor.icon"
|
||||
placeholder="e.g. fa-flask"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_le_field">
|
||||
<label>Default Operator Instructions</label>
|
||||
<textarea class="form-control" rows="3"
|
||||
t-model="state.libraryEditor.description"
|
||||
placeholder="Standing instructions for this step. Snapshot-copied into recipes when authors drag it in."/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_le_field">
|
||||
<label>Allowed Stations</label>
|
||||
<div class="o_fp_le_tank_chips">
|
||||
<t t-foreach="state.libraryEditor.tank_ids" t-as="tnk" t-key="tnk.id">
|
||||
<span class="o_fp_le_tank_chip">
|
||||
<t t-esc="tnk.name"/>
|
||||
<button class="o_fp_le_tank_remove"
|
||||
t-on-click="() => this.onRemoveTank(tnk.id)">×</button>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Search tanks to add…"
|
||||
t-on-input="onSearchTanks"/>
|
||||
<div class="o_fp_le_tank_results"
|
||||
t-if="state.tankSearchResults and state.tankSearchResults.length">
|
||||
<t t-foreach="state.tankSearchResults" t-as="tnk" t-key="tnk.id">
|
||||
<div class="o_fp_le_tank_option"
|
||||
t-on-click="() => this.onAddTank(tnk)">
|
||||
+ <t t-esc="tnk.name"/>
|
||||
<small t-if="tnk.code" class="text-muted ms-1">(<t t-esc="tnk.code"/>)</small>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_le_flags">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
t-model="state.libraryEditor.requires_signoff"/>
|
||||
Require QA Sign-off
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
t-model="state.libraryEditor.requires_predecessor_done"/>
|
||||
Require Predecessor Done
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
t-model="state.libraryEditor.requires_rack_assignment"/>
|
||||
Requires Rack Assignment
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
t-model="state.libraryEditor.requires_transition_form"/>
|
||||
Requires Transition Form
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ============== PROMPTS ============== -->
|
||||
<div class="o_fp_le_prompts">
|
||||
<div class="o_fp_le_prompts_header">
|
||||
<strong>Operation Measurements</strong>
|
||||
<span class="text-muted ms-2 small">
|
||||
What the operator records during this step. Snapshot-copied into recipes.
|
||||
</span>
|
||||
</div>
|
||||
<table class="table table-sm o_fp_inputs_table"
|
||||
t-if="state.libraryEditor.inputs and state.libraryEditor.inputs.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prompt</th>
|
||||
<th style="width:160px;">Type</th>
|
||||
<th style="width:90px;">Min</th>
|
||||
<th style="width:90px;">Max</th>
|
||||
<th style="width:80px;">Unit</th>
|
||||
<th style="width:60px;">Req</th>
|
||||
<th style="width:36px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="state.libraryEditor.inputs" t-as="inp" t-key="inp.id">
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
t-att-value="inp.name"
|
||||
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'name', ev)"/>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onLibraryInputChange(inp.id, 'input_type', ev.target.value)">
|
||||
<option value="text" t-att-selected="inp.input_type === 'text'">Text</option>
|
||||
<option value="number" t-att-selected="inp.input_type === 'number'">Number</option>
|
||||
<option value="boolean" t-att-selected="inp.input_type === 'boolean'">Yes/No</option>
|
||||
<option value="selection" t-att-selected="inp.input_type === 'selection'">Selection</option>
|
||||
<option value="date" t-att-selected="inp.input_type === 'date'">Date / Time</option>
|
||||
<option value="signature" t-att-selected="inp.input_type === 'signature'">Signature</option>
|
||||
<option value="time_hms" t-att-selected="inp.input_type === 'time_hms'">Time (HH:MM:SS)</option>
|
||||
<option value="time_seconds" t-att-selected="inp.input_type === 'time_seconds'">Time (sec)</option>
|
||||
<option value="temperature" t-att-selected="inp.input_type === 'temperature'">Temperature</option>
|
||||
<option value="thickness" t-att-selected="inp.input_type === 'thickness'">Thickness</option>
|
||||
<option value="pass_fail" t-att-selected="inp.input_type === 'pass_fail'">Pass / Fail</option>
|
||||
<option value="photo" t-att-selected="inp.input_type === 'photo'">Photo</option>
|
||||
<option value="multi_point_thickness" t-att-selected="inp.input_type === 'multi_point_thickness'">Multi-Point Thickness</option>
|
||||
<option value="bath_chemistry_panel" t-att-selected="inp.input_type === 'bath_chemistry_panel'">Bath Chemistry Panel</option>
|
||||
<option value="ph" t-att-selected="inp.input_type === 'ph'">pH</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
t-att-value="inp.target_min"
|
||||
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_min', ev)"/>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
t-att-value="inp.target_max"
|
||||
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_max', ev)"/>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
t-att-value="inp.target_unit"
|
||||
placeholder=""
|
||||
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_unit', ev)"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox"
|
||||
t-att-checked="inp.required"
|
||||
t-on-change="(ev) => this.onLibraryInputChange(inp.id, 'required', ev.target.checked)"/>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-link btn-sm text-danger p-0"
|
||||
title="Remove prompt"
|
||||
t-on-click="() => this.onRemoveLibraryInput(inp.id)">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p t-if="!state.libraryEditor.inputs or !state.libraryEditor.inputs.length"
|
||||
class="text-muted small">
|
||||
<t t-if="!state.libraryEditor.id">
|
||||
Save the step first, then add prompts.
|
||||
</t>
|
||||
<t t-else="">
|
||||
No prompts yet — operators will not be asked for measurements at runtime.
|
||||
</t>
|
||||
</p>
|
||||
<div class="o_fp_le_prompt_actions"
|
||||
t-if="state.libraryEditor.id">
|
||||
<button class="btn btn-link btn-sm"
|
||||
t-on-click="onAddLibraryInput">
|
||||
<i class="fa fa-plus"/> Add prompt
|
||||
</button>
|
||||
<button class="btn btn-link btn-sm"
|
||||
t-if="state.libraryEditor.default_kind"
|
||||
t-on-click="onSeedLibraryDefaults"
|
||||
title="Append the canonical prompts for this Step Kind. Idempotent — won't duplicate existing prompts.">
|
||||
<i class="fa fa-magic"/> Seed defaults from kind
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_library_editor_actions">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="onSaveLibraryEditor"
|
||||
t-att-disabled="state.libraryEditorBusy">
|
||||
<i class="fa fa-save me-1"/>Save
|
||||
</button>
|
||||
<button class="btn btn-secondary"
|
||||
t-on-click="onCancelLibraryEditor">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,7 +51,15 @@
|
||||
class="oe_stat_button" icon="fa-sitemap"
|
||||
invisible="node_type != 'recipe'">
|
||||
<field name="child_count" widget="statinfo"
|
||||
string="Steps"/>
|
||||
string="Tree Editor"/>
|
||||
</button>
|
||||
<button name="action_open_simple_editor" type="object"
|
||||
class="oe_stat_button" icon="fa-list-ol"
|
||||
invisible="node_type != 'recipe'">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Simple</span>
|
||||
<span class="o_stat_text">Editor</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.18.6.0',
|
||||
'version': '19.0.18.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -9,7 +9,7 @@ to depend on the configurator. Any field that references a model defined
|
||||
in configurator — like fp.pricing.rule, fp.part.catalog — must be
|
||||
declared here.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
@@ -73,3 +73,47 @@ class FpProcessNode(models.Model):
|
||||
help='Friendly label shown in the variant picker '
|
||||
'(e.g. "Standard ENP", "Selective Masking", "Rework").',
|
||||
)
|
||||
|
||||
# ---- Linked Parts (cloned recipes) --------------------------------------
|
||||
# On a shared template recipe, count + open all part-cloned recipe
|
||||
# roots that were copied from this template (cloned_from_id == self).
|
||||
# Only meaningful on shared templates (part_catalog_id IS NULL,
|
||||
# node_type='recipe').
|
||||
cloned_recipe_count = fields.Integer(
|
||||
string='Linked Part Recipes',
|
||||
compute='_compute_cloned_recipe_count',
|
||||
)
|
||||
|
||||
def _compute_cloned_recipe_count(self):
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
groups = Node._read_group(
|
||||
domain=[
|
||||
('cloned_from_id', 'in', self.ids),
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
],
|
||||
groupby=['cloned_from_id'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
counts = {src.id: count for src, count in groups}
|
||||
for rec in self:
|
||||
rec.cloned_recipe_count = counts.get(rec.id, 0)
|
||||
|
||||
def action_open_cloned_recipes(self):
|
||||
"""Open the list of part-cloned recipe roots that came from this
|
||||
template (i.e. cloned_from_id == self)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Linked Parts — %s', self.name),
|
||||
'res_model': 'fusion.plating.process.node',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('cloned_from_id', '=', self.id),
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
],
|
||||
'context': {
|
||||
'search_default_group_part': 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -217,6 +217,18 @@ export class FpPartProcessComposer extends Component {
|
||||
}
|
||||
|
||||
backToPart() {
|
||||
// Pop this composer off the action stack and restore the
|
||||
// previous controller (the part form the user came from).
|
||||
// Preserves the full breadcrumb trail — clearBreadcrumbs: true
|
||||
// would wipe parent crumbs (e.g. "Parts > 2144A6201-105").
|
||||
// Falls back to the part form only when restore() throws (e.g.
|
||||
// composer opened directly via URL with no prior crumb).
|
||||
try {
|
||||
this.action.restore();
|
||||
return;
|
||||
} catch (e) {
|
||||
// No prior controller — fall through to the part form.
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
|
||||
@@ -42,6 +42,28 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Extend form view: Linked Parts smart button ========== -->
|
||||
<!-- Smart button visible only on shared template recipes (no
|
||||
part_catalog_id). Opens the list of part-cloned recipe roots
|
||||
that were copied from this template. -->
|
||||
<record id="view_fp_process_node_form_linked_parts"
|
||||
model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.form.linked.parts</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="inherit_id"
|
||||
ref="fusion_plating.view_fp_process_node_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_open_cloned_recipes" type="object"
|
||||
class="oe_stat_button" icon="fa-link"
|
||||
invisible="node_type != 'recipe' or part_catalog_id">
|
||||
<field name="cloned_recipe_count" widget="statinfo"
|
||||
string="Linked Parts"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Extend list view: surface part column ========== -->
|
||||
<record id="view_fp_process_node_tree_part_scoped"
|
||||
model="ir.ui.view">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Rental',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.0.1',
|
||||
'category': 'Sales/Rental',
|
||||
'sequence': 200,
|
||||
'summary': "Rental lifecycle: agreements, deposits, auto-renewal, marketing, inspections.",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<record id="mail_template_rental_reminder" model="mail.template">
|
||||
<field name="name">Rental: Renewal Reminder</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="subject">{{ object.company_id.name }} - Rental {{ object.name }} Renews Soon</field>
|
||||
<field name="subject">{{ object.company_id.name }} - Your rental {{ object.name }} continues soon</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="use_default_to" eval="False"/>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
@@ -18,22 +18,28 @@
|
||||
<p style="color:#D69E2E;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Rental Renewal Notice</h2>
|
||||
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Your rental continues soon</h2>
|
||||
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
Your rental order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> is scheduled for automatic renewal on <strong style="color:#2d3748;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></strong>.
|
||||
Hi <t t-out="object.partner_id.name or ''"/>, this is a friendly heads-up that your rental <strong style="color:#2d3748;"><t t-out="object.name"/></strong> will continue automatically on <strong style="color:#2d3748;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy') or ''"/></strong>. <strong>You don't need to do anything</strong> — we'll just bill the recurring rental charge below.
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Renewal Details</td></tr>
|
||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Renewal Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy \'at\' hh:mm a') or ''"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Renewal Amount</td><td style="padding:10px 14px;color:#D69E2E;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 12px 0;">
|
||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Next Rental Period</td></tr>
|
||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:40%;">Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Continues on</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.rental_return_date and format_datetime(object.rental_return_date, tz=object.company_id.partner_id.tz or 'America/Toronto', dt_format='MMMM dd, yyyy') or ''"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Recurring Rental Charge</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="ctx.get('renewal_amount') or object._get_renewal_amount()" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
|
||||
</table>
|
||||
<div style="border-left:3px solid #D69E2E;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">If you would like to continue your rental, no action is needed. If you wish to cancel and schedule a pickup, click the button below.</p>
|
||||
<p style="color:#a0aec0;font-size:12px;line-height:1.5;margin:0 0 24px 0;font-style:italic;">
|
||||
This is the recurring rental charge only. Your security deposit stays on file and is not re-billed, and any one-time delivery or installation fees from your original order are not charged again.
|
||||
</p>
|
||||
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Happy with your rental? <strong>No action needed</strong> — it will simply continue. If you'd like us to come pick the item up instead, use the link below.</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:0 0 24px 0;">
|
||||
<a t-attf-href="{{ (object.get_base_url() or '') }}/rental/cancel/{{ ctx.get('cancel_token', '') }}" style="background-color:#E53E3E;color:#fff;padding:12px 30px;text-decoration:none;border-radius:4px;font-size:14px;font-weight:600;display:inline-block;">Request Cancellation & Pickup</a>
|
||||
<a t-attf-href="{{ (object.get_base_url() or '') }}/rental/cancel/{{ ctx.get('cancel_token', '') }}" style="background-color:#ffffff;color:#4A5568;padding:10px 24px;text-decoration:none;border-radius:4px;font-size:13px;font-weight:600;display:inline-block;border:1px solid #cbd5e0;">Schedule a pickup instead</a>
|
||||
</div>
|
||||
<p style="color:#a0aec0;font-size:12px;line-height:1.5;margin:0 0 0 0;text-align:center;">
|
||||
Questions? Just reply to this email and we'll be happy to help.
|
||||
</p>
|
||||
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
|
||||
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
|
||||
</t>
|
||||
|
||||
BIN
fusion_rental/models/__pycache__/sale_order.cpython-312.pyc
Normal file
BIN
fusion_rental/models/__pycache__/sale_order.cpython-312.pyc
Normal file
Binary file not shown.
@@ -842,6 +842,7 @@ class SaleOrder(models.Model):
|
||||
if template:
|
||||
template.with_context(
|
||||
cancel_token=cancel_token,
|
||||
renewal_amount=self._get_renewal_amount(),
|
||||
).send_mail(self.id, force_send=True)
|
||||
|
||||
def _send_renewal_reminder_sms(self, cancel_token):
|
||||
|
||||
Reference in New Issue
Block a user