Compare commits

..

60 Commits

Author SHA1 Message Date
gsinghpal
ba7c028c30 CHANGES 2026-06-04 09:49:51 -04:00
gsinghpal
41ce3784d7 fix(fusion_plating_reports): page-break-inside on CoC part rows
Final-review follow-up: per the entech wkhtmltopdf rule, page-break-inside
must sit on each <tr>, not just the <table>, so a large multi-part cert
can't split a part row mid-row under the company header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:59:29 -04:00
gsinghpal
98873c4e39 feat(fusion_plating): cert backfill migration + version bumps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:54:54 -04:00
gsinghpal
28e5e7f9de feat(fusion_plating_jobs): group WOs by recipe step structure
Replace the old 5-tuple (recipe.id, part, spec, thickness, serial) grouping
key with a structural signature so multiple parts that share the same recipe
step tree (ENP clones) collapse onto one combined work order. Add three
helpers: _fp_recipe_signature, _fp_line_express_signature, _fp_line_group_key.
Add TransactionCase test covering merge, non-merge, and masking-split cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:49:51 -04:00
gsinghpal
c71c60350b feat(fusion_plating_jobs): traveller lists all parts in the batch
Additive 'Batch parts' roster in the Item Information cell, shown when a
WO covers 2+ distinct parts (the labeled block still details the primary
part). Keeps the existing table layout intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:41:16 -04:00
gsinghpal
ba1e15da07 feat(fusion_plating_reports): CoC parts table loops part_line_ids 2026-06-03 22:39:20 -04:00
gsinghpal
f1bf5b214c feat(fusion_plating_jobs): multi-part cert creation + requirement union
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:37:35 -04:00
gsinghpal
983e576fdc feat(fusion_plating_certificates): Parts page on certificate form 2026-06-03 22:28:27 -04:00
gsinghpal
7cbf4f25df feat(fusion_plating_certificates): add fp.certificate.part child model + ACL
Adds the fp.certificate.part model (one row per part on a combined CoC),
the part_line_ids O2M on fp.certificate, and ACL rows for all three
plating roles. No views yet — Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:27:00 -04:00
gsinghpal
e35c120af8 docs(fusion_plating): implementation plan - WO grouping by recipe + combined CoC
8 bite-sized tasks (cert part-line model -> form -> creation -> CoC report
-> traveller -> grouping switch -> migration -> verify). Cert multi-part
support lands before the grouping flip so it is never a compliance
regression. Tests are committed TransactionCase artifacts run on an
Enterprise env (local Community cannot install fusion_plating); plus a
read-only entech signature smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:01:12 -04:00
gsinghpal
e34892f5c0 docs(fusion_plating): spec - WO grouping by recipe structure + combined multi-part CoC
Group SO lines into one fp.job per distinct plating process (identical
recipe step structure) instead of one WO per line; make the Certificate
of Conformance multi-part via a new fp.certificate.part child model + CoC
parts-table loop + migration backfill.

Grounded in a read-only entech audit: 13 WOs -> 4 on real orders;
per-part recipe clones are structurally identical (same node_type +
kind_code + name sequence). cloned_from_id/process_type_id are empty on
existing data, so grouping keys off the step structure.

Phase 1 (this spec): grouping + combined cert + report + traveller +
migration. Phase 2 (deferred): per-part thickness + per-part stickers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:52:50 -04:00
gsinghpal
774d21863e Merge branch 'feat/assessment-visit' into main
Brings the fusion_portal assessment-visit (Start-a-Visit) feature into main: funding-source selectors on accessibility forms, ADP multi-device grouping + combination guard, Assessment Visit model + accessibility funding grouping, assessment visit workspace, portal entry tile, and email consolidation (10 commits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:29:11 -04:00
gsinghpal
2f8b6b3ae0 changes 2026-06-03 19:50:45 -04:00
gsinghpal
837198fc8a fix(fusion_plating_jobs): commit res_users_views.xml referenced by manifest but never added
The jobs __manifest__.py data list references views/res_users_views.xml
(Plating Signature pad on the user preferences + full user form), and the
file was deployed live to entech, but it was never `git add`ed — so the
committed manifest pointed at a file absent from the repo. Fresh installs /
CI (and any clean-checkout deploy) failed with
`FileNotFoundError: .../fusion_plating_jobs/views/res_users_views.xml`.
Retrieved the live file from entech and committed it as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:45:22 -04:00
gsinghpal
5a3c660322 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	fusion_plating/fusion_plating/__manifest__.py
#	fusion_plating/fusion_plating_jobs/__manifest__.py
#	fusion_plating/fusion_plating_jobs/models/fp_job_step.py
#	fusion_plating/fusion_plating_shopfloor/__manifest__.py
#	fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
2026-06-03 15:37:38 -04:00
gsinghpal
235c8fba39 feat(fusion_plating): Express masking reference images → mask step + workstation viewer
Order-entry shortcut: when masking is toggled ON for an Express order line,
an amber "MASK" button appears to attach reference image(s)/PDF(s). The files
ride the existing _fp_apply_express_overrides_to_job path onto the job's
masking step, so the operator sees exactly what to mask — no recipe edit or
custom prompt needed.

- configurator: masking_attachment_ids on the wizard line + SO line;
  action_upload_masking_ref; override branch writes refs onto mask steps;
  amber multi-file MASK button (express_action_btns) shown when masking is on.
- jobs: x_fc_masking_attachment_ids on fp.job.step (per-step) + computed
  rollup on fp.job; office "Masking Refs" form page (readonly preview).
- shopfloor: workspace step payload carries masking_refs (sudo'd attachment
  read, rule 13m); operator sees thumbnail/PDF tiles on the mask step that
  open in Odoo's full-screen FileViewer (zoom + swipe).

Verified end-to-end on entech: SO-line refs land on the mask step + job
rollup (WO-30091); payload mask_refs shape correct (is_image, /web/image).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:12:18 -04:00
gsinghpal
b52b8758a1 feat(fusion_plating_jobs): internal sticker QR — bigger + quiet-zone crop (match external)
Applied the same QR treatment to the Internal (Layout A) header QR: bumped the
box to 30mm and added the ~10% quiet-zone crop wrapper so the pattern fills the
box (finders intact), centered via the table cell. HD (1000px) already applied.
Verified live (WO-30072).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:47:17 -04:00
gsinghpal
910ccd0fc6 fix(fusion_plating_jobs): vertically center the full-width QR (no-tags external)
The no-tags QR used line-height centering, which wkhtmltopdf renders slightly
high (extra white at the bottom). Switched to a single-cell table with
vertical-align:middle (same mechanism as the with-tags case) so the QR centers
in its cell with balanced top/bottom margin. Verified live (WO-30072).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:22:16 -04:00
gsinghpal
2b0add3a2e feat(fusion_plating_jobs): crop QR quiet-zone so the pattern fills the box (bigger)
The barcode bakes a ~12% white quiet-zone border around the QR. Render the QR
oversized inside an overflow:hidden wrapper offset to clip ~10% off each edge
(under the quiet zone — finder patterns stay intact), so the black pattern
fills the box and reads bigger. Applied to both the full-width (no-tags) and
shared (with-tags) QR. White label cell around the wrapper preserves the scan
margin. Verified live (WO-30072, WO-30090) — finders intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:16:48 -04:00
gsinghpal
f00a039fc2 fix(fusion_plating_jobs): HD QR on job stickers (300x300 -> 1000x1000)
All three barcode_data_uri('QR', ...) calls bumped from 300px to 1000px
(under Odoo's 1.2M-pixel barcode cap, per rule 14). At the ~34mm display
size that's ~750 dpi — crisp on the label printer. Verified: PDF now embeds
a 1000x1000 QR XObject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:09:40 -04:00
gsinghpal
5646c97f67 feat(fusion_plating_jobs): external sticker — QR fills full width when no MASK/BAKE tags
Taller QR row (30->36mm) and the QR now expands to a full-width centered ~34mm
when a job has neither masking nor baking (was leaving the right half empty);
when tags are present, QR ~32mm on the left with MASK/BAKE stacked on the right.
Logo/WO-band/field rows trimmed to fund the bigger QR. Verified live (WO-30072
no-tags full QR; WO-30090 BAKE tag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:55:02 -04:00
gsinghpal
fec72a70c1 feat(fusion_plating_jobs): external sticker — merge QR row + flags into one, bigger QR
External Job Sticker rail: combined the separate QR row and MASK/BAKE flags
row into a single row — QR enlarged to ~28mm on the LEFT, MASK/BAKE badges
stacked on the RIGHT. WO band trimmed 18->16mm to free the vertical space.
Verified live on entech (WO-30090, BAKE present).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:46:10 -04:00
gsinghpal
d531faad12 feat(fusion_plating): box-level tracking (fp.box) + thermal job-sticker redesign
Box registry: new fp.box model (fusion_plating_receiving), one record per
received box, auto-created when a receiving is marked Counted (idempotent
_fp_sync_boxes — grows/shrinks with box_count_in, never touches an advanced
box). Status received -> racked -> in_process -> packed -> shipped, per-box
scannable QR (/fp/box/<id> controller). Backfill migration for receivings
counted before tracking shipped. Boxes list/kanban/form + receiving smart
button.

Job stickers redesigned (thermal label, 6x4 in / 152x102mm, mm layout @
paperformat dpi=96 so mm maps 1:1 in wkhtmltopdf — see rule 14):
- Internal Job Sticker = Layout A, ONE per job (shop notes from
  x_fc_internal_description, job QR).
- External Job Sticker = Layout B, ONE per fp.box (BOX n/N, per-box QR,
  factory company logo, customer-facing notes). Dynamic MASK badge
  (x_fc_masking_enabled) + BAKE block (x_fc_bake_instructions), length-tiered
  notes font. Display logic in fp.job._fp_sticker_data().

Also retains the SO/WO box-sticker MemoryError fix in report_fp_wo_sticker.xml
(per-box loop sourced from fp.receiving.box_count_in + 100-label safety cap).

Verified live on entech: 111 boxes backfilled (31 receivings), External renders
one page per box, Internal one per job, scan endpoint 303->login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:21:54 -04:00
gsinghpal
951cad0f81 fix(fusion_plating_shopfloor): stop breadcrumb URL growth; embed racking panel in step row
Surface switches between the plant kanban and job workspace used
doAction({..., target: "current"}), which APPENDS to Odoo 19's
controller/breadcrumb stack -- so the /odoo/... URL grew one segment
per switch, and the tablet lock/unlock window.location.reload()
preserved the bloat, compounding it every lock cycle. Switched those
navigations to target: "main" (Odoo sets clearBreadcrumbs when
action.target === "main" -> _computeStackIndex returns 0 -> stack
resets to a single action). The genuine one-level drill-down
(onJumpToBlocker -> hold/NCR form) keeps target: "current" so
breadcrumb-back still works there.

Also embeds the multi-rack racking panel inside the Racking step row
(gated on step.area_kind == 'racking') instead of a job-level section,
tying it to the recipe's Racking step.

19.0.37.0.1 -> 19.0.37.0.3. Both changes live on entech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:08:50 -04:00
gsinghpal
acd1fc9f8f docs(fusion_plating): racking multi-rack + WO grouping design spec & Phase 1 plan
Approved design for splitting a WO's parts across multiple racks + grouping
multiple WOs on one rack, plus the Phase 1 implementation plan (split +
independent movement). Phases 2 (grouping + Station screen) and 3 (Plant
Kanban rollup) are noted for follow-up plans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:37:18 -04:00
gsinghpal
5424c785d9 feat(fusion_plating_shopfloor): mobile responsiveness, boxes stepper, racking panel
- Plant Kanban + Job Workspace made phone-responsive: height:100% + single
  internal scroll (was 100vh, broke mobile scroll), compact header/workflow
  bar, receiving part-line stacking so fields don't overflow, responsive
  lock-screen tile grid.
- +/- stepper on the receiving "Boxes received" field.
- Multi-rack Racking panel (Phase 1): split a WO's parts across racks
  (+Add Rack / Divide Equally / manual qty + Unassigned counter) on the Job
  Workspace, shown only when the WO is at the Racking step (area_kind based,
  excludes De-Racking). New /fp/racking/* controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:37:11 -04:00
gsinghpal
ae256b4480 feat(fusion_plating_receiving): open Receiving menu to Technician + technician shipping ACLs
- Backend "Shipping & Receiving" menu lowered from Shop Manager to Technician
  (all higher roles inherit Technician, so none lose access).
- Technicians granted r/w/c on fp.outbound.package and the manual/generate
  label wizards — shipping parity with shop managers so they can generate
  outbound labels and manage packages in the backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:36:59 -04:00
gsinghpal
696f5da662 feat(fusion_plating_jobs): de-rack/bake area_kind name override + rack-load Phase 1
- _compute_area_kind: name-based override so de-rack/de-mask steps land in the
  De-Racking column and bake/oven steps in Baking, regardless of a mis-tagged
  recipe kind (fixed WO cards scattering into the wrong shop-floor columns).
- fp.rack.load jobs extension: racking-step resolution by area_kind (not the
  corrupt kind), equal-split/override ops, fp.job qty_racked/unracked rollups,
  and independent rack movement (per-line moves) + de-racking unrack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:36:47 -04:00
gsinghpal
fc3fd513a9 fix(fusion_plating_configurator): Express Order customers default customer_rank=1
Customers created from the Express Order / quote-configurator / part-catalog
pickers now default customer_rank=1, so they stay visible in those pickers and
the Customers menu (were landing at rank 0 and disappearing). The field context
is a real dict, not a string — Odoo 19 web_read does with_context(**context),
which throws TypeError on a str and broke the form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:36:38 -04:00
gsinghpal
a19a299c7f feat(fusion_plating): Office User grants Contact Creation + rack-load Phase 1 models
- group_fp_office_user now implies base.group_partner_manager so every office/
  manager role can create contacts (Technicians excluded). Fixes the live
  "create a company contact, it doesn't show" report (AccessError on save).
- New fp.rack.load + fp.rack.load.line models (multi-rack split at Racking,
  Phase 1) with sequence, ACLs, equal-split math, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:36:27 -04:00
gsinghpal
78fa8f07ee fix(fusion_clock): stop stale missed-clock-in nag; add Owner role + attendance exemption
The "explain your missed clock-out" dialog (driven by hr.employee.
x_fclk_pending_reason) was set by the absence + auto-clock-out crons but only
cleared by the systray reason dialog -- never by the kiosk/NFC clock paths that
staff actually use. During the kiosk rollout the absence cron flagged the whole
company (hundreds of "absent" logs); those stale flags then nagged everyone
forever, even while currently clocked in.

Fixes:
- Clear x_fclk_pending_reason on every successful clock-in (portal, systray,
  PIN kiosk, NFC kiosk). Back on the clock => no nag.
- get_status / dashboard never report pending while checked-in or exempt; the
  systray also guards the dialog client-side.
- Absence detection no longer sets x_fclk_pending_reason (an absence has no
  "departure time" to explain). It still logs 'absent' + notifies the office.
- One-time migration (19.0.4.2.0) clears existing stale flags.

Owner / attendance exemption:
- New "Owner" role (top of the Fusion Clock access dropdown, implies Manager)
  plus a per-employee "Exempt from Attendance" checkbox.
- hr.employee._fclk_is_attendance_exempt(); the absence, auto-clock-out,
  reminder and weekly-summary crons all skip exempt employees, and the dialog
  is suppressed for them.

Tests: tests/test_pending_reason_exempt.py (13 cases). Full fusion_clock suite
green except pre-existing env-sensitive failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:54:00 -04:00
gsinghpal
71f4c41d5c merge: NexaCloud->Odoo billing cutover (spec + plan00 hermetic suite + plan01 cancel endpoint) 2026-06-02 09:17:43 -04:00
gsinghpal
2f6a8b33a9 docs(billing): CLAUDE.md centralized-billing + test-harness section; plan-01 note
Document fusion_centralize_billing as the Lago-superseding billing engine and the
isolated odoo-nexa test recipe (fresh DB + l10n_ca; never -u against live nexamain;
log_level/workers gotchas). Plan-01 doc: corrected the unsafe test command + added the
harness section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:17:41 -04:00
gsinghpal
4b832e7445 Update 2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md 2026-06-02 09:13:35 -04:00
gsinghpal
f67cefc213 feat(billing): _api_cancel_subscription service method + unit tests
Plan 01 (NexaCloud cutover) Task 1: cancel/close a subscription with the same
service-scoped authorization as _api_record_usage (resolve via
_fc_resolve_subscription; partner must be linked to this service). Idempotent
(no-op if already 6_churn). 5 unit tests, verified green on fcb_test
(fresh + l10n_ca). DELETE route + HttpCase follow in Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:37 -04:00
gsinghpal
658611457e docs(CLAUDE.md): westin clone-verify recipe + orphaned-tax-FK trap + fusion_portal note
Capture the operational knowledge from the fusion_portal assessment-visit deploys:
the isolated _test addons-path clone-verify technique, the orphaned-tax-FK restore
trap (and the proof that prod -u is safe without touching the orphans because Odoo
skips a present FK), the backup/stage/swap/-u/cache-bust deploy flow with restart
gating, the surgical branch->main merge for branches that predate other merges, and
a fusion_portal module note (ENTERPRISE-only; visit funding-grouping architecture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:33 -04:00
gsinghpal
4df35448c2 docs(fusion_plating): partial-order rollout fixes + open-items handoff
Consolidated handoff added to the Partial Order Handling section: the bugs
that only live tablet testing surfaced (phantom stage cards, scan-button
icons/labels, dark-mode undefined --bs-* vars, from-step predecessor block,
seeded-stage auto-finish on drain, gating fall-forward) and the open items
(discoverability badges, Scrap/Rework standalone buttons, automated tests
not written, dark-mode chip polish). Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:06:53 -04:00
gsinghpal
1d6797f0d2 Merge fusion_repairs maintenance foundation (Plan 1) + 2 install fixes + CLAUDE.md rule 17 into main 2026-06-02 09:03:17 -04:00
gsinghpal
4622521729 docs(CLAUDE.md): Odoo 19 url_encode-in-mail-template rule + corrected fusion_repairs note
Rule 17: url_encode (and werkzeug url helpers) are not in the Odoo 19 mail.template QWeb render context -> opaque 'issue with this value' ParseError at install. fusion_repairs note corrected: NOT Community-installable (Enterprise ai+knowledge via fusion_portal->fusion_claims); test on the westin-fr-test Enterprise sandbox; --workers 0 + log_level=warn test-runner gotchas; noupdate templates load on fresh install only. Version 19.0.2.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:02:27 -04:00
gsinghpal
40a29081bf fix(fusion_portal): readable gradient for Start-a-Visit tile (live westin 19.0.2.10.1)
Inline tile gradient (no !important) was overridden by the theme .card rule,
rendering near-white with invisible white text. Dedicated .portal-visit-card
class (blue->indigo, distinct from the green New Assessment tile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:59:59 -04:00
gsinghpal
11ab261ad9 test(billing): make fusion_centralize_billing suite hermetic (green baseline)
- test_usage / test_webhook setUp: get-or-create the cpu_seconds metric and
  nexacloud service so the suite no longer collides with existing rows.
- test_invoice_ledger: add _fc_ensure_ca_billing_env (activate CAD + a 13%
  sale tax matching _fc_tax_for) so the ledger tests pass on a clean DB.

Canonical test DB: a FRESH db with l10n_ca installed (a prod clone collides
on fixed-code fixtures across 5 test files). Full suite now exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:59:31 -04:00
gsinghpal
2285b7b814 fix(fusion_portal): readable gradient for the Start-a-Visit tile
The tile used an inline background gradient with no !important, so the theme's
.card background rule overrode it - the tile rendered near-white with invisible
white text. Replace with a dedicated .portal-visit-card class (mirrors
.portal-new-assessment-card: gradient !important, transparent card-body, white
text, styled icon-circle) in a distinct blue->indigo gradient so the two
featured tiles read as different. Bump 19.0.2.10.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:58:11 -04:00
gsinghpal
859a327738 fix(fusion_plating_jobs): gating steps fall forward to next stage's column
A "Ready for X" gating step (fp.step.kind code='gating') maps to
area_kind='receiving' in the taxonomy. For a MID-recipe gate (e.g.
"Ready for processing" between Racking and Plating) that snapped the
job's card back to the far-left Receiving column when work advanced into
it — the job looked like it vanished from the board.

_compute_area_kind now detects gating via the stable kind code and
resolves a gating step's column to the NEXT non-gating step's area (so
"Ready for processing" shows in Plating), keeping cards flowing
left→right. Falls back to the last real stage for a trailing gate.
Non-gating steps unchanged. Helpers: _fp_is_gating_step / _fp_raw_area_kind
(no recursion) / _fp_resolve_area_kind.

area_kind is a stored compute — recomputed all 537 live steps on entech.
Verified: WO-30061 "Ready for processing" area receiving→plating, card now
renders in the Plating column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:53:47 -04:00
gsinghpal
a52f2bbebd fix(fusion_plating_jobs): gating steps fall forward to next stage's column
A "Ready for X" gating step (fp.step.kind code='gating') maps to
area_kind='receiving' in the taxonomy. For a MID-recipe gate (e.g.
"Ready for processing" between Racking and Plating) that snapped the
job's card back to the far-left Receiving column when work advanced into
it — the job looked like it vanished from the board.

_compute_area_kind now detects gating via the stable kind code and
resolves a gating step's column to the NEXT non-gating step's area (so
"Ready for processing" shows in Plating), keeping cards flowing
left→right. Falls back to the last real stage for a trailing gate.
Non-gating steps unchanged. Helpers: _fp_is_gating_step / _fp_raw_area_kind
(no recursion) / _fp_resolve_area_kind.

area_kind is a stored compute — recomputed all 537 live steps on entech.
Verified: WO-30061 "Ready for processing" area receiving→plating, card now
renders in the Plating column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:51:24 -04:00
gsinghpal
9a8e1d7ab5 feat(fusion_portal): ADP/express->visit wiring, visit entry tile, email consolidation (live on westin 19.0.2.10.0)
- express save captures visit_id; visit-linked submit defers SO creation
  (saves draft + signature) and returns to the visit for grouping.
- portal dashboard 'Start a Visit' tile for sales reps.
- fix duplicate-authorizer completion email; visit grouped SOs email once per SO.
- define visit._assessment_sale_type (ADP grouping key) - fixes AttributeError.

Verified on a westin-v19 clone (load + ADP-grouping + combination-guard smoke
test, mail neutralised) then deployed to westin prod 19.0.2.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:50:25 -04:00
gsinghpal
837e7b09b7 fix(fusion_portal): define visit._assessment_sale_type (ADP grouping key)
action_complete_visit referenced self._assessment_sale_type() to group ADP
devices by funding, but the method was never defined - any visit containing an
ADP device would have raised AttributeError. Mirrors
fusion.assessment._create_draft_sale_order: adp_odsp for ODSP client streams,
adp otherwise. Caught by the clone ADP-grouping smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:48:16 -04:00
gsinghpal
ed91135a3f feat(fusion_portal): wire ADP/express into visit + portal entry tile + email consolidation
- express save captures visit_id; when visit-linked, action=submit saves the
  ADP assessment as a draft (signature + Page 11 PDF still captured) and returns
  to the visit instead of completing into a standalone SO, so the visit groups
  the ADP devices into one funding-routed order. Non-visit express flow unchanged.
- portal dashboard: featured 'Start a Visit' tile (sales reps) -> /my/visit/new.
- fix duplicate-authorizer email: _send_completion_notifications no longer
  re-emails the authorizer (already emailed with the full report by
  _send_assessment_completed_email); it now only notifies the client.
- visit grouped accessibility SOs now send one office completion email per SO.

Bump 19.0.2.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:39:37 -04:00
gsinghpal
451fc5eafd docs(billing): NexaCloud->Odoo cutover spec + plan 01 (cancel endpoint)
Increment design (phase #2 of the approved 2026-05-27 centralized-billing
spec) to make Odoo fusion_centralize_billing the system of record for
NexaCloud billing: build -> import -> dual-run -> gated flip, NexaCloud first,
one subscription per deployment, go-forward billing only. Plan 01 = the Odoo
subscription-cancel endpoint (test-first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:38:48 -04:00
gsinghpal
7fcf38ca82 fix(fusion_plating_jobs): first/seeded stage never auto-finished on drain
_fp_try_autofinish_on_drain guarded on _fp_has_real_incoming() — the WRONG
direction. The first stage (e.g. Racking) is fed by the qty_at_step seed,
not an incoming move, so it never auto-finished when all its parts were
sent forward (operator sent everything out of Racking, step stayed
in_progress at qty 0). Now guards on a real OUTGOING move (parts left),
which covers the seeded first stage.

Still best-effort + gated: button_finish runs the required-input / sign-off
gates, so a step with an unrecorded required input (Racking's "Count the
Parts") won't auto-finish — it stays in_progress for a manual finish after
the input is recorded. Verified on entech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:37:29 -04:00
gsinghpal
64a202ff6e fix(fusion_plating_shopfloor): partial advance blocked by from-step predecessor
The Move dialog's predecessor check flagged every unfinished step before
the destination — including the from_step itself, which is in-progress by
definition when advancing partial parts out of it. So any "Send → next"
to a not-yet-started step showed a hard "Predecessor not done: <from_step>"
blocker and greyed out SEND (reproduced on WO-30061: Racking → Ready for
processing). This broke partial advance for ALL quantities, not just
1-part orders.

Fix: _blockers_for_move only blocks unfinished steps STRICTLY BETWEEN
from_step and to_step (you'd be skipping an incomplete intermediate
stage). Immediate-next advance is allowed; skip-ahead still blocked;
backward (rework) moves unblocked. Verified on entech: blocker no longer
fires for Racking → Ready for processing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:30:12 -04:00
gsinghpal
13fabb0e79 feat(fusion_portal): assessment-visit redesign - live on westin 19.0.2.9.0
Bundles multiple assessments per home visit; on completion groups them by
funding workflow (x_fc_sale_type) into one draft sale order per workflow
(March of Dimes / ADP / ODSP / WSIB / private / hardship / insurance).
Adds the mobility scooter ADP device type, the power-mobility home-access
rule, ADP multi-device combination guard, and the portal visit workspace.

Verified on a westin-v19 clone (clean registry load + funding-grouping
smoke test) then deployed to westin prod (fusion_portal 19.0.2.9.0).
Prod's pre-existing orphaned tax links were preserved (Odoo skips existing
FKs), pending a later audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:23:43 -04:00
gsinghpal
20de9a6b69 chore(fusion_portal): bump to 19.0.2.9.0 for assessment-visit redesign
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:16:56 -04:00
gsinghpal
21cfd55419 feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path)
Adds the portal workspace: /my/visit/new starts a visit; /my/visit/<id> shows the
add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a
deferred client+funding form, and a Complete button). Accessibility forms launched
from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the
controller captures it and skips the per-assessment SO) - the VISIT completion then
creates the grouped per-funding sale orders.

NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI.
Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:37:08 -04:00
gsinghpal
89467432a7 feat(fusion_portal): Phase 2b - ADP multi-device grouping + combination guard
The visit groups its ADP assessments by funding type onto ONE ADP order (first
device creates the SO via the existing express completion; the rest attach),
enforcing the combination rule: at most one seated-mobility device (manual WC /
power WC / scooter) + optionally one walker, no duplicates. Also fixes a Phase 1b
bug - it called action_complete() (needs signatures, returns an action dict) for
ADP; now uses action_complete_express() which returns the SO.

Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:38 -04:00
gsinghpal
319de06ca6 fix(fusion_plating_shopfloor): finish-dialog text readable in dark mode (real fix)
Root cause (verified against the live compiled bundle): Odoo's backend
CSS never DEFINES --bs-body-color / --bs-secondary-color / --bs-*-bg as
custom properties (0 definitions; they're only referenced). So every
color: var(--bs-body-color, #1d1d1f) — and the earlier --bs-secondary-color
swap — resolved to the dark hex fallback in BOTH light and dark mode.
That's why the prior swaps never worked. Backend dark mode here is runtime
[data-bs-theme=dark] + SCSS literals, not those vars.

Fix: the finish-block dialog text now INHERITS the modal's theme-correct
colour (same as the readable title + "Count the Parts" list items) — the
broken line was the only one setting an explicit var() colour. Tinted
banners use translucent rgba() instead of color-mix-with-undefined-var.
Verified in the served bundle: o_fp_finish_block_msg{font-weight:500;}
(no colour override).

CLAUDE.md dark-mode guidance corrected (it had wrongly recommended those
undefined vars).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:09 -04:00
gsinghpal
e0ddd9ef40 feat(fusion_portal): Phase 2a - mobility scooter ADP type + power-mobility home-access rule
Adds 'scooter' as a 4th ADP equipment type with a lean Express-form section
(scooter type + max range) and the power-mobility home-accessibility hard rule
(scooter + powerchair): "is the home usable inside and outside, no lifting?" - if No,
prompts adding an accessibility item (ramp / porch lift). Captures
x_fc_power_home_accessible + notes; the section toggles via the existing
equipment-select JS; controller parses the new fields.

NOT YET: ADP multi-device + combination rules (the bigger restructure).
Untested locally (Enterprise dep) - to be verified on a westin-v19 clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:22:47 -04:00
gsinghpal
0499a1ad2e fix(fusion_plating_shopfloor): finish-dialog message readable in dark mode
The "N required input(s) haven't been recorded yet" line still read as
dark/dim in dark mode after the --text-secondary→--bs-secondary-color
swap, because --bs-secondary-color is muted/low-opacity. That line is
primary instruction text, so use the full-contrast var(--bs-body-color)
instead (+ font-weight 500). Reserve --bs-secondary-color for genuinely
secondary text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:11:42 -04:00
gsinghpal
b17bd615bf feat(fusion_portal): Phase 1b - Assessment Visit model + accessibility funding grouping
Adds fusion.assessment.visit: the hub that bundles a home visit's assessments and,
on completion, groups its ACCESSIBILITY assessments by funding workflow
(x_fc_sale_type) and creates ONE draft sale order per workflow, reusing the existing
MOD/ODSP/etc. pipelines + the chatter-note pattern. ADP assessments keep one-SO-each
for now (ADP multi-device grouping is Phase 2).

- New model + sequence (VISIT/YYYY/NNNN) + ACL + backend list/form/menu.
- visit_id added to fusion.assessment, fusion.accessibility.assessment, sale.order.
- action_complete_visit() group-and-routes; MOD $15k cap + income-threshold flag
  surfaced (informational, no auto-enforcement).

NOT YET: completion-email consolidation; ADP multi-device (Phase 2); portal
add-as-you-go workspace (Phase 3). Untested locally (Enterprise knowledge dep) -
to be verified on a westin-v19 clone before prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:00:53 -04:00
gsinghpal
e36aaab306 fix(fusion_portal): validate funding_source in accessibility save (parity with booking)
Coerce an unexpected/tampered funding_source to direct_private instead of passing
it raw into create() (which would raise on the Selection field). Mirrors the
/book-assessment controller; the whitelist is derived from the model selection so
it auto-covers hardship and any future values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:38:42 -04:00
gsinghpal
37efc5b858 feat(fusion_portal): funding-source selector on accessibility forms
Reps can now mark an accessibility assessment's funding source on the web form
(Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) so the
generated draft sale order routes to the correct funding pipeline instead of
always defaulting to private pay. Adds Hardship to the x_fc_funding_source
selection + sale_type_map; the new form <select> is auto-serialised by the
existing FormData submit, and accessibility_assessment_save now maps
funding_source -> x_fc_funding_source. The model + SO routing were already in
place (2026-04 audit fix) — this closes the form + controller gap.

Plan: docs/superpowers/plans/2026-06-02-accessibility-funding-selector.md
Spec: docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:33:24 -04:00
128 changed files with 10210 additions and 337 deletions

View File

@@ -35,6 +35,8 @@
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: <the entire body html>` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML.
## 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
@@ -96,7 +98,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
## Module-Specific Notes
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 111 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_portal`.
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.3.0`** (Plan-1 maintenance foundation added 2026-06-02). **NOT Community-installable** — it transitively pulls in Enterprise `ai` + `knowledge` (`fusion_repairs → fusion_portal → fusion_claims → ai`; `fusion_portal → knowledge`), so it can NOT be installed or tested on local `odoo-modsdev` (Community) — the old `-d fusion-dev -u fusion_repairs` recipe does NOT work. **Test on Enterprise:** an isolated `westin-fr-test` DB on the `odoo-westin` host (clone of prod `westin-v19`; a fresh-DB clone install also needs a one-time orphaned-FK cleanup because prod has orphaned account/tax m2m rows). First-ever clean install surfaced + fixed 2 bugs (url_encode → rule 17; menu parent defined after its children) in commit `903ceb10`. **Not production-deployed** to Westin yet. **Test-runner gotchas on that prod-config container:** `--test-enable` SILENTLY SKIPS all tests without `--workers 0`; the conf's `log_level=warn` hides test output (add `--log-level=test`); the post_install phase also trips on a pre-existing module, so verify behaviour via `odoo shell` rather than the test runner. `mail_template_data.xml` is `noupdate=1` → template edits load on a FRESH install (the prod deploy) but NOT on `-u` of an already-installed DB. Outstanding: maintenance booking (Plan 2), visit log (Plan 3), backfill wizard (Plan 4), office follow-up crons (Plan 5), RingCentral SMS.
- **fusion_portal** (formerly `fusion_authorizer_portal`) — authorizer/sales-rep portal; **ENTERPRISE-only** (depends `knowledge` → cannot run on local Community; verify on a westin clone, see *Westin Prod* below). **Assessment-visit flow LIVE on westin, v19.0.2.10.1.** A `fusion.assessment.visit` bundles the assessments from one home visit and, on completion (`action_complete_visit`), groups them by funding workflow (`x_fc_sale_type`) into ONE draft sale order per workflow (MoD/ADP/ODSP/WSIB/private/hardship/insurance) — never one combined SO, never one-per-item-within-a-funding. ADP devices group into one order (combination guard: ≤1 seated {wheelchair/powerchair/scooter} + ≤1 walker); accessibility items group per funding. Reps enter via the "Start a Visit" dashboard tile → `/my/visit/new`; the express/accessibility forms carry `?visit_id=` and defer SO creation to the visit. Renaming the technical name needs a DB rename — see [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql).
## Workflow
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
@@ -138,6 +141,19 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
- `fusionapps.code_snippets` — reference code
- `fusionapps.quick_commands` — deployment and admin commands
## Westin Prod — Deploy & Clone-Verify (fusion_portal et al.)
Westin prod: host `odoo-westin`, app container `odoo-dev-app`, db container `odoo-dev-db`, DB `westin-v19` (user `odoo`, pw `DevSecure2025!`), addons `/opt/odoo/custom-addons` → `/mnt/extra-addons`, Enterprise `/mnt/enterprise-addons`, conf `/etc/odoo/odoo.conf`. ENTERPRISE env — modules depending on `knowledge` (fusion_portal → fusion_claims) cannot run on local Community, so verify on a clone before prod.
**Clone-verify a change (prod-safe, isolated — prod files + live DB untouched):**
1. Clone online: `docker exec -e PGPASSWORD='DevSecure2025!' odoo-dev-db sh -c 'dropdb -U odoo --if-exists westin-v19-visittest; createdb -U odoo -O odoo westin-v19-visittest && pg_dump -U odoo westin-v19 | psql -U odoo -q -d westin-v19-visittest'` (~2 min, ~152M -Fc).
2. Stage the branch module into an isolated dir INSIDE the addons path: `/opt/odoo/custom-addons/_test/<module>`, then `-u <module> --stop-after-init --no-http --db_host db --db_port 5432 --db_user odoo --db_password 'DevSecure2025!' --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/extra-addons/_test,/mnt/enterprise-addons,/mnt/extra-addons`. The `/mnt/extra-addons/_test` prefix SHADOWS prod's copy (first matching path wins); deps load from the real `/mnt/extra-addons`.
3. Smoke-test via `odoo shell -d westin-v19-visittest` (same addons-path); `env.cr.rollback()` at the end. To exercise email paths WITHOUT sending: `UPDATE ir_mail_server SET active=false;` AND in the shell `env['ir.mail_server'].__class__.send_email = lambda self, message, *a, **k: 'noop'` (`odoo shell` rejects `--smtp-server`).
**THE ORPHANED-TAX-FK TRAP** (cost real diagnosis time): westin-v19 has ~3300 orphaned rows in `product_taxes_rel` + ~3300 in `product_supplier_taxes_rel` (`tax_id` → deleted `account_tax`), under FKs that are `convalidated=true` (taxes deleted via an FK-bypassing path; PG never re-checks a validated constraint). A plain `pg_dump | psql` clone can't recreate a *validating* FK over orphaned data → the FK is lost on the clone → Odoo `check_foreign_keys` tries to add it → `ForeignKeyViolation: Key (tax_id)=(N) is not present in account_tax` → "Failed to load registry". **Fix ON THE CLONE only:** `DELETE FROM <t> WHERE tax_id NOT IN (SELECT id FROM account_tax)` across every `%_rel` table with a tax column. **Prod `-u` is SAFE without touching the orphans** — prod's FK already exists, so Odoo skips it (it never re-validates a present FK); proven empirically by replicating FK-present+orphan on a clone and running `-u` (exit 0, orphan untouched). Owner is auditing the orphans — do NOT delete them on prod without sign-off.
**Deploy:** backup (`docker exec ... pg_dump -Fc -U odoo westin-v19 > /opt/odoo/backups/<name>.dump` + `cp -r` the module dir to `/opt/odoo/backups/` — OUTSIDE the addons path, never a `*.bak` dir inside it) → `scp` branch to `/opt/odoo/staging/<module>` → swap into `/opt/odoo/custom-addons/<module>` → `-u <module>` → `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` → `docker restart odoo-dev-app`. **Gate the restart on `-u` exit 0**; on failure restore the dir backup and do NOT restart. When a feature branch predates main's other merges, merge to `main` **surgically** (temp worktree off `origin/main` + `git checkout <branch> -- <module>` → commit → fast-forward push) so you don't revert parallel sessions' work.
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
@@ -232,3 +248,41 @@ catches undefined names instantly.
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
all 50 ENTECH tickets, "Mine" shows the count matching the owner's profile email.
Tracebacks live in `/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).
## Fusion Centralized Billing (`fusion_centralize_billing`) — engine + test harness
Odoo (`odoo-nexa`, live DB `nexamain`) is being made the single billing brain for every
NexaSystems app (NexaCloud, NexaDesk/Fusion-Chat, NexaMaps), **superseding Lago**. The
module adds only the metering + integration layer (service registry, identity links,
metric/charge catalog, aggregate-push usage engine, inbound Lago-shaped REST API at
`/api/billing/v1/*`, outbound HMAC webhooks, dual-run reconciliation); all financial
behaviour is native Odoo **Enterprise** (`sale_subscription` + `payment_stripe` +
`account_accountant`). Design + rollout live in `docs/superpowers/specs/`
(`2026-05-27-nexa-billing-centralized-design.md` = architecture;
`2026-06-02-nexacloud-odoo-billing-cutover-design.md` = NexaCloud pilot: build → import →
dual-run → gated flip) and `docs/superpowers/plans/`.
**Testing it — NOT on local `odoo-modsdev` (community) and NEVER `-u` against live `nexamain`.**
It needs Enterprise deps, so tests run on `odoo-nexa` in an **isolated throwaway container**
against a **fresh** DB with the Canadian localization:
```
ssh odoo-nexa
# fresh DB (inside odoo-nexa-db): dropdb --if-exists fcb_test; createdb fcb_test
cp -a /opt/odoo/custom-addons /opt/odoo/custom-addons-staging # edit/sync HERE, never the live module dir
docker run --rm --network odoo_odoo-network \
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro -v /opt/odoo/staging-data:/var/lib/odoo \
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test --db_host=db --db_user=odoo \
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
--without-demo=all --test-enable --test-tags /fusion_centralize_billing \
-i l10n_ca,fusion_centralize_billing --stop-after-init --no-http
```
Iterate with `-u fusion_centralize_billing` (reuse fcb_test). Gotchas that cost hours:
- **`l10n_ca` is required** — the ledger tests need a Canadian CoA + active CAD + 13% HST.
- A **prod clone is the wrong base** — its existing rows collide with fixed-code test fixtures
(`nexacloud` service / `cpu_seconds` metric) across 5 test files.
- odoo.conf sets `log_level=warn`, so **passing tests log nothing** — exit 0 alone does NOT
prove tests ran (a tag matching zero tests is also exit 0). Confirm execution with
`--log-handler=odoo.addons.fusion_centralize_billing.tests:INFO` (look for `Starting
<Class>.<method>`). The **exit code is authoritative** (1 on any failure).
- Do **NOT** pass `--workers=0` (blanks captured stdout) or `--logfile=/dev/stdout` (errors out).

View File

@@ -0,0 +1,127 @@
# KICKOFF BRIEF — Implement "Technician Service Booking & Auto-Quote" (hands-off)
You are a fresh Claude Code session. **Implement this feature end-to-end, autonomously, from the
plans below.** The design is already locked through brainstorming — **do NOT re-design or
re-brainstorm.** Build it.
---
## 1. Mission
Replace the raw `fusion.technician.task` booking modal with a polished **OWL "Book a Service"
wizard** that: captures the client (incl. brand-new clients inline), books the technician task,
prices the call-out from an **editable rate table**, and **auto-creates a draft repair Sale Order**
— with correct, consistent timezone handling. Works in dark + light.
## 2. Read these first, in order
1. `K:\Github\Odoo-Modules\CLAUDE.md` (repo Odoo-19 rules) + the global `K:\Github\CLAUDE.md`.
2. Spec: `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md`
3. **Plan 1** (do first): `docs/superpowers/plans/2026-06-03-service-rates-foundation-plan.md`
4. **Plan 2** (do second): `docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md`
5. UI source of truth (port its markup/CSS): `docs/superpowers/mockups/technician-booking-wizard.html`
The plans are bite-sized (TDD, exact files, full code). They are the authority — follow them
task-by-task. The spec/mockup are context.
## 3. Method
- Use the **`superpowers:subagent-driven-development`** skill (the plan headers require it). One
task at a time; write test → implement → verify → **commit per task** with the messages in the plan.
- **Order: Plan 1 fully, then Plan 2** (Plan 2 consumes Plan 1's `fusion.service.rate`).
- Before writing any model/view/OWL code, obey repo rule #1: **read the real reference from Docker
first** (`docker exec odoo-modsdev-app cat …` or, for the Enterprise classes, read the on-disk
source) — never code Odoo APIs from memory. The plans flag the specific signatures to confirm
(`_get_local_tz`, `_compute_datetimes`, `_calculate_travel_time`, real task field names like
`in_store`/`client_name`/`address_lat`, the `crm.tag` vs `sale.order` tag model).
## 4. Branch
```bash
git -C K:\Github\Odoo-Modules checkout main
git -C K:\Github\Odoo-Modules checkout -b claude/technician-service-booking
```
Create it **off `main`** — NOT off `claude/fusion-schedule-audit-fixes` (that branch has unrelated
calendar-sync fixes). The spec/plans/mockup are already on disk under `docs/superpowers/`; keep them.
## 5. Hard constraints (do not violate)
- **Odoo 19 idioms** (from CLAUDE.md): declarative `models.Constraint` / `models.Index` (never
`_sql_constraints`); `group_ids` not `groups_id`; HTTP routes `type="jsonrpc"`; backend OWL uses
**standalone `rpc()`** from `@web/core/network/rpc` (not `useService("rpc")`), client action
`static props = ["*"]`; **dark mode** = branch on `$o-webclient-color-scheme` at SCSS compile
time and register the SCSS in **both** `web.assets_backend` **and** `web.assets_web_dark`; new
fields use the **`x_fc_`** prefix; **Canadian English**; any `message_post(body=…)` HTML wrapped
in `Markup()`.
- **Enterprise-only:** `fusion_claims` pulls `ai` → it **cannot install on local Community
(`odoo-modsdev`)**. Do **not** attempt `-d modsdev -u fusion_claims`. (`fusion_tasks` alone may
install locally — the tz-fix test in Plan 2 Task 1 can be tried there; everything else is clone-only.)
- **The design is LOCKED** — implement exactly §6 below; don't add scope or re-open decisions.
## 6. Locked design (build exactly this)
- **Time:** 12-hour **AM/PM** entry on the wizard (custom control — Odoo's native widget is 24h).
Fix the `fusion_tasks` tz bug: the `_inverse_datetime_*` methods must use `self._get_local_tz()`
(same resolver as `_compute_datetimes`), not `self.env.user.tz`.
- **Client:** inline **new-client** (name / phone / email / address) on the page; **no forced SO**
(relax `fusion_claims` `_check_order_link` to a no-op); find-or-create the `res.partner` on save
(match by email then phone).
- **View:** a **full OWL client action** wizard (complete design freedom), ported from the mockup,
dark + light.
- **Pricing → SO:** pick service type → call-out fee → **auto draft repair `sale.order`** with the
call-out line **+ auto per-km line** for Rush/After-Hours (qty = `travel_distance_km × 2`,
$0.70/km). On-screen **estimate is UI-only** (labour/parts added later as actuals). Tag the SO
(`x_fc_is_service_repair` + a "Service Repair" tag).
- **Rates are an editable table** — `fusion.service.rate` with a **Service Rates** menu. The card
only **seeds** it (`noupdate=1`). Pricing is read from this table, never hardcoded.
- **Rate card seed:** Standard call $95 / Rush $120 / After-Hours $140; Lift & Elevating $160 /
**Rush $185** / **After-Hours $205** (the $185/$205 are *suggested* fills — seed them but they're
confirm-pending; leave a code comment). Labour: on-site $85, in-shop $75 (reuse existing `LABOR`
product), lift $110. Per-km $0.70 ×2-way. Delivery/setup: local $35 / outside $60 / rush $60+km /
lift-chair $120 / bed $120 / stairlift $300 / removal $300. **In-shop = no call-out, labour @ $75.**
- **Module split:** the tz fix goes in **`fusion_tasks`**; everything else (rate model, products,
menu, resolver, SO builder, `action_book_from_wizard`, controller, OWL wizard, SCSS, entry point)
goes in **`fusion_claims`**.
## 7. Verification (you probably can't reach the Enterprise clone — handle both cases)
- **Always do (no Odoo needed):** after each Python file, run `python -m py_compile <file>` and
`python -m pyflakes <file>` (or `docker exec odoo-modsdev-app python3 -m pyflakes …`). **Fix every
warning you introduce.** This is your local gate.
- **Full tests + smoke require a Westin Enterprise clone.** A one-command harness already exists:
`scripts/verify_service_booking.sh` (runs on the `odoo-westin` host: clones the DB, the
orphaned-tax-FK cleanup, stages the branch, `-u` + tests, PASS/FAIL; `--deploy` ships on green).
- If you have access to `odoo-westin`: push the branch, then run that script (verify-only first).
- If you do **not**: finish all code, ensure `py_compile`/`pyflakes` are clean, **commit the
branch task-by-task**, and clearly report **"clone-verification pending — run
`scripts/verify_service_booking.sh` on odoo-westin."** Do not fake a green test.
- **Never deploy to prod yourself.** Leave `--deploy` to the human.
## 8. Definition of done
- [ ] Branch `claude/technician-service-booking` off `main`.
- [ ] Plan 1 + Plan 2 implemented, **committed task-by-task** with the plans' commit messages.
- [ ] `py_compile` + `pyflakes` clean on every touched `.py`.
- [ ] OWL wizard renders the mockup layout in **both** light and dark bundles.
- [ ] Either **clone-verified GREEN** via the script, **or** branch committed + verification
explicitly flagged pending (with the exact command to run).
- [ ] A short final report: what was built, files changed, how to verify + deploy (`scripts/verify_service_booking.sh`),
and the one open business item (confirm Lift Rush/After-Hours $185/$205).
## 9. Don't
- Don't test on `odoo-modsdev` (Community — `fusion_claims` won't install).
- Don't re-brainstorm or change the design in §6.
- Don't hardcode prices (they live in `fusion.service.rate`).
- Don't deploy to prod or run `--deploy` — hand that to the human.
- Don't change the suggested $185/$205 silently — keep them, flag them confirm-pending.
---
### Optional: launch it headless
```bash
# from the repo root, on a machine with this checkout:
claude -p "$(cat docs/superpowers/EXECUTE-technician-service-booking.md)" --permission-mode acceptEdits
```
…or just paste this file into a fresh Claude Code session and say "go".

View File

@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Book a Service — Mockup v2</title>
<style>
:root, [data-theme="light"] {
--page:#eef0f3; --panel:#e6e9ed; --card:#ffffff; --border:#d8dadd;
--text:#1f2430; --muted:#6b7280; --faint:#9ca3af;
--field:#ffffff; --field-border:#cfd3d8; --field-focus:#3a8fb7;
--chip:#f1f4f7; --shadow:0 1px 3px rgba(16,24,40,.08),0 1px 2px rgba(16,24,40,.06);
--accent:#2e7aad; --accent-soft:#e8f2f8; --ok:#16a34a; --star:#f5b301; --money:#0f7d4e; --money-soft:#e7f6ee;
}
[data-theme="dark"] {
--page:#14161b; --panel:#1b1e24; --card:#22262d; --border:#343a42;
--text:#e7eaef; --muted:#9aa3af; --faint:#6b7480;
--field:#1a1d23; --field-border:#3a4049; --field-focus:#4aa3cf;
--chip:#2a2f37; --shadow:0 1px 3px rgba(0,0,0,.4);
--accent:#3a8fb7; --accent-soft:#19303d; --ok:#22c55e; --star:#f5b301; --money:#34d27f; --money-soft:#15281f;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--page); color:var(--text);
font-family:'Inter','Helvetica Neue',Helvetica,Arial,system-ui,sans-serif; font-size:14px; }
.wrap { max-width:1000px; margin:24px auto; padding:0 18px; }
.dialog { background:var(--panel); border:1px solid var(--border); border-radius:16px;
box-shadow:0 12px 40px rgba(16,24,40,.16); overflow:hidden; }
.topbar { background:linear-gradient(135deg,#5ba848 0%,#3a8fb7 60%,#2e7aad 100%);
padding:17px 24px; display:flex; align-items:center; justify-content:space-between; color:#fff; }
.topbar h1 { font-size:19px; font-weight:700; margin:0; }
.topbar .sub { font-size:12.5px; opacity:.9; margin-top:2px; }
.theme-btn { background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.35); color:#fff;
border-radius:20px; padding:6px 14px; font-size:12.5px; cursor:pointer; font-weight:600; }
.stepper { display:flex; gap:6px; padding:11px 24px; background:var(--panel); border-bottom:1px solid var(--border); flex-wrap:wrap; }
.step { font-size:11.5px; font-weight:600; color:var(--faint); padding:5px 13px; border-radius:20px; background:var(--chip); }
.step.active { color:#fff; background:linear-gradient(135deg,#3a8fb7,#2e7aad); }
.step.draft { margin-left:auto; color:var(--money); background:var(--money-soft); }
.body { padding:20px 24px 6px; }
.grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media (max-width:780px){ .grid { grid-template-columns:1fr; } }
.card { background:var(--card); border:1px solid var(--border); border-radius:13px; padding:16px 17px; box-shadow:var(--shadow); }
.card.span2 { grid-column:1 / -1; }
.card h3 { margin:0 0 13px; font-size:11.5px; font-weight:700; letter-spacing:.7px; text-transform:uppercase;
color:var(--muted); display:flex; align-items:center; gap:7px; }
.card h3 .dot { width:7px; height:7px; border-radius:50%; background:linear-gradient(135deg,#5ba848,#2e7aad); }
.card h3 .tag { margin-left:auto; font-size:10px; font-weight:700; color:var(--money); background:var(--money-soft);
padding:2px 8px; border-radius:10px; letter-spacing:.3px; }
label.fl { display:block; font-size:12px; font-weight:600; color:var(--muted); margin:0 0 5px; }
.row { margin-bottom:12px; } .row:last-child { margin-bottom:0; }
.two { display:grid; grid-template-columns:1fr 1fr; gap:11px; }
.three { display:grid; grid-template-columns:1fr 1fr 1fr; gap:9px; }
input.f, select.f, textarea.f { width:100%; background:var(--field); color:var(--text); border:1px solid var(--field-border);
border-radius:9px; padding:9px 11px; font-size:13.5px; font-family:inherit; outline:none; transition:border .15s,box-shadow .15s; }
input.f:focus, select.f:focus, textarea.f:focus { border-color:var(--field-focus);
box-shadow:0 0 0 3px color-mix(in srgb, var(--field-focus) 22%, transparent); }
textarea.f { resize:vertical; min-height:56px; }
.hint { font-size:11px; color:var(--faint); margin-top:5px; }
.with-icon { position:relative; } .with-icon .pin { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#5ba848; font-size:16px; }
.seg { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; gap:3px; }
.seg button { border:none; background:transparent; color:var(--muted); font-weight:600; font-size:12.5px; padding:6px 14px;
border-radius:7px; cursor:pointer; font-family:inherit; }
.seg button.on { background:var(--card); color:var(--accent); box-shadow:var(--shadow); }
.seg.full { display:flex; } .seg.full button { flex:1; }
.timepick { display:inline-flex; align-items:stretch; gap:7px; }
.timepick select.f { width:auto; padding-right:24px; }
.ampm { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; }
.ampm button { border:none; background:transparent; color:var(--muted); font-weight:700; font-size:12px; padding:6px 12px; border-radius:7px; cursor:pointer; }
.ampm button.on { background:var(--accent); color:#fff; }
.endtime { font-size:13px; color:var(--muted); margin-top:7px; } .endtime b { color:var(--text); }
.avail { display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:600; color:var(--ok);
background:color-mix(in srgb,var(--ok) 14%,transparent); padding:3px 9px; border-radius:20px; margin-top:6px; }
.opt { display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border); }
.opt:last-child { border-bottom:none; }
.opt .lab { font-size:13.5px; font-weight:500; } .opt .lab small { display:block; color:var(--faint); font-weight:400; font-size:11.5px; }
.sw { width:42px; height:24px; border-radius:20px; background:var(--field-border); position:relative; cursor:pointer; transition:background .15s; flex-shrink:0; }
.sw::after { content:''; position:absolute; width:18px; height:18px; border-radius:50%; background:#fff; top:3px; left:3px; transition:left .15s; box-shadow:0 1px 2px rgba(0,0,0,.3); }
.sw.on { background:var(--ok); } .sw.on::after { left:21px; }
/* fee readout inside Service & Pricing */
.feeline { display:flex; align-items:center; justify-content:space-between; background:var(--money-soft);
border:1px solid color-mix(in srgb,var(--money) 35%,transparent); border-radius:10px; padding:11px 14px; margin-top:4px; }
.feeline .lbl { font-size:12.5px; font-weight:600; color:var(--text); }
.feeline .lbl small { display:block; color:var(--faint); font-weight:400; font-size:11px; }
.feeline .amt { font-size:20px; font-weight:800; color:var(--money); }
/* ESTIMATE strip */
.estimate { grid-column:1/-1; background:var(--money-soft); border:1px solid color-mix(in srgb,var(--money) 40%,transparent);
border-left:5px solid var(--money); border-radius:13px; padding:15px 18px; display:flex; align-items:center; gap:20px; flex-wrap:wrap; }
.estimate .breakdown { display:flex; gap:18px; flex-wrap:wrap; flex:1; }
.estimate .bk { } .estimate .bk .k { font-size:10.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--faint); }
.estimate .bk .v { font-size:15px; font-weight:700; margin-top:1px; }
.estimate .total { text-align:right; }
.estimate .total .k { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--money); font-weight:700; }
.estimate .total .v { font-size:27px; font-weight:800; color:var(--money); line-height:1; }
.estimate .total .note { font-size:11px; color:var(--faint); margin-top:3px; }
.foot { display:flex; align-items:center; justify-content:flex-end; gap:11px; padding:16px 24px; background:var(--panel); border-top:1px solid var(--border); }
.foot .spacer { margin-right:auto; font-size:12px; color:var(--faint); }
.btn { border:none; border-radius:10px; padding:11px 18px; font-size:13.5px; font-weight:600; cursor:pointer; font-family:inherit; }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
.btn.primary { color:#fff; background:linear-gradient(135deg,#5ba848,#2e7aad); box-shadow:0 3px 10px color-mix(in srgb,#2e7aad 40%,transparent); }
.hide { display:none !important; }
.note { max-width:1000px; margin:14px auto 40px; padding:0 18px; color:var(--muted); font-size:12.5px; }
.note code { background:var(--chip); padding:1px 6px; border-radius:5px; }
</style>
</head>
<body>
<div class="wrap">
<div class="dialog">
<div class="topbar">
<div><h1>Book a Service</h1><div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div></div>
<button class="theme-btn" onclick="toggleTheme()">◐ Light / Dark</button>
</div>
<div class="stepper">
<span class="step active">Scheduled</span><span class="step">En Route</span>
<span class="step">In Progress</span><span class="step">Completed</span>
<span class="step draft">● Draft repair SO will be created</span>
</div>
<div class="body">
<div class="grid">
<!-- CUSTOMER -->
<div class="card">
<h3><span class="dot"></span>Customer</h3>
<div class="row">
<div class="seg full">
<button class="on" id="segExisting" onclick="custMode('existing')">Existing customer</button>
<button id="segNew" onclick="custMode('new')">New client</button>
</div>
</div>
<div id="custExisting">
<div class="row">
<label class="fl">Search by phone, name or SO</label>
<input class="f" placeholder="e.g. (416) 555-0142 …" value="(416) 555-0142 — Margaret Chen">
<div class="hint">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
</div>
</div>
<div id="custNew" class="hide">
<div class="row two">
<div><label class="fl">Client name *</label><input class="f" placeholder="Full name"></div>
<div><label class="fl">Phone *</label><input class="f" placeholder="(416) 555-…"></div>
</div>
<div class="row"><label class="fl">Email</label><input class="f" type="email" placeholder="client@email.com"></div>
<div class="row"><label class="fl">Address</label>
<div class="with-icon"><input class="f" placeholder="Start typing an address…"><span class="pin">📍</span></div>
</div>
<div class="row three">
<div><label class="fl">Unit</label><input class="f" placeholder="#"></div>
<div><label class="fl">Buzz</label><input class="f" placeholder="—"></div>
<div><label class="fl">City</label><input class="f" placeholder="City"></div>
</div>
<div class="hint">Contact is created &amp; linked on save — all from this page.</div>
</div>
</div>
<!-- SERVICE & PRICING -->
<div class="card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="row two">
<div>
<label class="fl">Device being serviced</label>
<select class="f" id="device" onchange="onDevice()">
<option value="standard">Mobility Scooter</option>
<option value="standard">Powerchair</option>
<option value="standard">Wheelchair</option>
<option value="lift">Stairlift</option>
<option value="lift">Patient / Ceiling Lift</option>
<option value="standard">Lift Chair</option>
<option value="standard">Hospital Bed</option>
<option value="standard">Other</option>
</select>
</div>
<div>
<label class="fl">Issue / symptom</label>
<input class="f" placeholder="e.g. won't power on">
</div>
</div>
<div class="row" id="callTypeRow">
<label class="fl">Service call type</label>
<select class="f" id="callType" onchange="recalc()">
<option data-fee="95" data-km="0">Standard Service Call — $95 (incl. 30 min labour)</option>
<option data-fee="160" data-km="0">Lift &amp; Elevating Service Call — $160 (incl. 30 min)</option>
<option data-fee="120" data-km="1">Rush Service Call — $120 + $0.70/km ×2-way</option>
<option data-fee="140" data-km="1">After-Hours Service Call — $140 + $0.70/km ×2-way</option>
</select>
<div class="hint">Auto-suggested from the device — change if needed.</div>
</div>
<div class="feeline" id="feeBox">
<div class="lbl">Call-out fee<small id="feeSub">Standard · includes 30 min labour</small></div>
<div class="amt" id="feeAmt">$95</div>
</div>
<div class="hint" id="inshopNote" style="display:none;">In-shop job — no call-out fee; labour billed at $75/hr.</div>
</div>
<!-- SCHEDULE -->
<div class="card">
<h3><span class="dot"></span>Schedule</h3>
<div class="row two">
<div><label class="fl">Date</label><input class="f" type="date" value="2026-06-03"></div>
<div><label class="fl">Duration</label>
<select class="f" id="dur" onchange="recalc();endTime()">
<option value="0.5">30 min</option><option value="1" selected>1 hour</option>
<option value="1.5">1.5 hours</option><option value="2">2 hours</option><option value="3">3 hours</option>
</select></div>
</div>
<div class="row">
<label class="fl">Start time</label>
<div class="timepick">
<select class="f" id="hh" onchange="endTime()"><option>9</option><option>10</option><option>11</option><option>12</option><option>1</option><option>2</option><option>3</option><option>4</option></select>
<select class="f" id="mm" onchange="endTime()"><option>:00</option><option>:15</option><option>:30</option><option>:45</option></select>
<div class="ampm"><button class="on" onclick="ampm(this)">AM</button><button onclick="ampm(this)">PM</button></div>
</div>
<div class="endtime">Ends at <b id="endlbl">10:00 AM</b> · your local time</div>
</div>
<div class="row">
<label class="fl">Technician</label>
<select class="f"><option>— Choose —</option><option selected>Dave Wilson</option><option>Priya Anand</option></select>
<span class="avail">● 3 open slots before 5:00 PM</span>
</div>
</div>
<!-- LOCATION -->
<div class="card">
<h3><span class="dot"></span>Location</h3>
<div class="opt" style="border:none; padding-top:0;">
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $75/hr</small></div>
<div class="sw" id="inshopSw" onclick="toggleShop(this)"></div>
</div>
<div id="addrBlock">
<div class="row"><label class="fl">Job address</label>
<div class="with-icon"><input class="f" placeholder="Auto-fills from customer…" value="88 Bloor St E, Toronto"><span class="pin">📍</span></div>
</div>
<div class="row two">
<div><label class="fl">Unit / Suite</label><input class="f" placeholder="#"></div>
<div><label class="fl">Buzz code</label><input class="f" placeholder="—"></div>
</div>
</div>
</div>
<!-- JOB DETAILS -->
<div class="card span2">
<h3><span class="dot"></span>Job details</h3>
<div class="two">
<div class="row"><label class="fl">Work description</label><textarea class="f" placeholder="Symptom, what to check, history…"></textarea></div>
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" placeholder="Batteries, controller, casters…"></textarea></div>
</div>
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" onclick="sw(this)"></div></div>
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" onclick="sw(this)"></div></div>
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw on" onclick="sw(this)"></div></div>
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw on" onclick="sw(this)"></div></div>
</div>
<!-- ESTIMATE -->
<div class="estimate">
<div class="breakdown">
<div class="bk"><div class="k">Call-out</div><div class="v" id="eCall">$95</div></div>
<div class="bk"><div class="k">Est. labour</div><div class="v" id="eLab">$85 · 1h</div></div>
<div class="bk" id="eKmBox" style="display:none;"><div class="k">Travel ($0.70/km ×2)</div><div class="v" id="eKm">$18</div></div>
</div>
<div class="total"><div class="k">Estimated total</div><div class="v" id="eTotal">$180</div>
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
</div>
</div>
</div>
<div class="foot">
<span class="spacer">Local time · America/Toronto · 13 km away</span>
<button class="btn ghost">Cancel</button>
<button class="btn primary">Book &amp; Create SO</button>
</div>
</div>
</div>
<div class="note">
Mockup v2 — demo-wired (theme, customer mode, device→call-type, in-shop, AM/PM, switches, live estimate).
Real build = an OWL client action; <b>Book &amp; Create SO</b> calls one server method that find-or-creates the
contact, creates the <code>fusion.technician.task</code> + a draft <code>sale.order</code> with the call-out line
(+ auto per-km for rush/after-hours, from the computed distance). Rate-card items are seeded as service products.
Toggle <b></b> top-right for dark/light.
</div>
<script>
const DIST_2WAY = 26, KM_RATE = 0.70; // demo: 13km away, 2-way
let inshop=false, ap='AM';
function toggleTheme(){ const h=document.documentElement; h.dataset.theme=h.dataset.theme==='dark'?'light':'dark'; }
function custMode(m){ const ex=m==='existing';
segExisting.classList.toggle('on',ex); segNew.classList.toggle('on',!ex);
custExisting.classList.toggle('hide',!ex); custNew.classList.toggle('hide',ex); }
function onDevice(){ const cat=device.value; callType.selectedIndex = cat==='lift'?1:0; recalc(); }
function ampm(el){ [...el.parentNode.children].forEach(b=>b.classList.remove('on')); el.classList.add('on'); ap=el.textContent; endTime(); }
function sw(el){ el.classList.toggle('on'); }
function toggleShop(el){ el.classList.toggle('on'); inshop=el.classList.contains('on');
addrBlock.classList.toggle('hide',inshop); callTypeRow.classList.toggle('hide',inshop);
feeBox.classList.toggle('hide',inshop); inshopNote.style.display=inshop?'block':'none'; recalc(); }
function endTime(){ const h=+hh.value, m=+mm.value.replace(':',''), dur=+document.getElementById('dur').value;
let mins=((h%12)+(ap==='PM'?12:0))*60+m+dur*60;
let eh=Math.floor(mins/60)%24, em=mins%60; endlbl.textContent=(eh%12||12)+':'+String(em).padStart(2,'0')+' '+(eh>=12?'PM':'AM'); }
function money(n){ return '$'+n.toFixed(n%1?2:0); }
function recalc(){
const dur=+document.getElementById('dur').value;
const labRate = inshop?75:85;
let callout=0, km=0, sub='', kmFlag=false;
if(!inshop){ const o=callType.options[callType.selectedIndex];
callout=+o.dataset.fee; kmFlag=o.dataset.km==='1';
feeAmt.textContent=money(callout); feeSub.textContent=o.text.split('—')[0].trim()+(kmFlag?' · + travel':' · incl. 30 min labour');
if(kmFlag) km=DIST_2WAY*KM_RATE;
}
// labour: first 30 min included on standard/lift call (not rush/afterhours which are time-based but keep simple)
const incl = (!inshop && !kmFlag) ? 0.5 : 0;
const billLabHrs = Math.max(0, dur - incl);
const lab = billLabHrs*labRate;
eCall.textContent = inshop?'—':money(callout);
eLab.textContent = money(lab)+' · '+billLabHrs+'h @ $'+labRate;
eKmBox.style.display = kmFlag?'block':'none'; eKm.textContent=money(km);
eTotal.textContent = money(callout+lab+km);
}
endTime(); recalc();
</script>
</body>
</html>

View File

@@ -0,0 +1,298 @@
# NexaCloud→Odoo Cutover — Plan 01: Odoo subscription-cancel endpoint
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the one inbound endpoint NexaCloud's deprovision path needs — cancel (close) a subscription — to `fusion_centralize_billing`, with the same auth model the other endpoints already use.
**Architecture:** New `fusion.billing.service._api_cancel_subscription(external_ref)` resolves the subscription via the existing `_fc_resolve_subscription`, enforces the same "partner must be linked to this service" authorization as `_api_record_usage`, and closes it with Odoo 19's native `set_close()` (→ `subscription_state='6_churn'`). A `DELETE /api/billing/v1/subscriptions/<ref>` route wraps it.
**Tech Stack:** Odoo 19 Enterprise (`sale_subscription`), Python, Odoo `TransactionCase` tests.
**Spec:** [`2026-06-02-nexacloud-odoo-billing-cutover-design.md`](../specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md) §4.1.3
---
## ⚠ Test harness (supersedes any `-d nexamain` command below)
**NEVER run `-u` / `--test-enable` against the live `nexamain` DB.** Tests run in an **isolated throwaway container** against a dedicated DB, reading a **separate** addons copy so the live module is never touched:
```
# 1) edit files on branch feat/nexacloud-odoo-billing-cutover, then sync the changed
# module files to the staging addons copy on odoo-nexa:
# /opt/odoo/custom-addons-staging/fusion_centralize_billing/...
# 2) run (ssh odoo-nexa):
docker run --rm --network odoo_odoo-network \
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro \
-v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro \
-v /opt/odoo/staging-data:/var/lib/odoo \
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test \
--db_host=db --db_user=odoo \
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
--test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel \
-u fusion_centralize_billing --stop-after-init --no-http
```
- `fcb_test` is a **fresh** install DB (not a prod clone). `nexamain_staging` is a prod clone kept for later integration/importer plans.
- **Scope each step's run to the relevant test class** (`:TestSubscriptionCancel`, `:TestSubscriptionCancelHttp`). The wider suite is **not hermetic yet** (see Plan 00) — `test_invoice_ledger` needs a configured Canadian CoA/active CAD/HST; `test_usage`/`test_webhook` collide with cloned prod data. Don't gate this plan on those.
- The per-step `Run:` blocks below that mention `-d nexamain` are **illustrative only — use this harness instead.**
> **Prerequisite — Plan 00 (make the suite hermetic):** before green-baseline TDD, fix fixtures so the whole suite passes on `fcb_test`: `setUp` should get-or-create the `nexacloud`/`cpu_seconds` records (idempotent), and a test-setup helper must ensure an active CAD currency + a Canadian CoA + a 13% HST sale tax. Tracked as its own plan; recommended before Plan 01 execution.
---
## Increment plan sequence (this is Plan 01 of 6)
Each is its own plan doc + its own working, testable deliverable. Order reflects dependencies:
1. **Odoo: subscription-cancel endpoint***this doc* (unblocked; no external decisions).
2. **Odoo: NexaCloud charge catalog** — products + `sale.subscription.plan` (`NC-PLAN-*`) + `fusion.billing.charge` (cpu_seconds quota/overage). **Blocked on confirming real NexaCloud plan pricing/quotas** (open review Q#1) before it can be written placeholder-free.
3. **Odoo: importer go-forward subscriptions** — extend `wizards/import_wizard.py` to create one shadow `sale.order` per active deployment with go-forward `next_invoice_date`; the safety test that asserts **no past-period invoice** is the centrepiece (guards against the 2026-05-27 Lago re-bill).
4. **NexaCloud: adapter activation** — config (`odoo_billing_base_url`/`api_key`/staged enable), customer + subscription create/cancel calls, reconciliation-amount push.
5. **NexaCloud: control-loop receiver** — activate `/billing/webhooks/central` HMAC verify → suspend/restore/deprovision via `network_isolation`/`throttle_checker`/`resource_manager`.
6. **Dual-run + gated flip** — operational runbook: shadow ≥1 cycle, reconcile to cent, then the reversible flip flag.
---
## File structure (this plan)
- Modify: `fusion_centralize_billing/models/service.py` — add `_api_cancel_subscription`.
- Modify: `fusion_centralize_billing/controllers/api.py` — add `DELETE /subscriptions/<ref>`.
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` — service-method + authorization tests.
- Modify: `fusion_centralize_billing/tests/__init__.py` — import the new test module.
Run tests (from `K:\Github\CLAUDE.md` workflow, adapted to odoo-nexa):
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init"
```
---
### Task 1: `_api_cancel_subscription` service method
**Files:**
- Modify: `fusion_centralize_billing/models/service.py` (add method after `_api_create_subscription`, ~line 250)
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py`
- Modify: `fusion_centralize_billing/tests/__init__.py`
- [ ] **Step 0: Verify the Odoo 19 close method (do NOT code from memory — per `K:\Github\CLAUDE.md`)**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app grep -nE 'def set_close|def set_open|6_churn' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head"
```
Expected: a `def set_close(self...)` exists and sets `subscription_state='6_churn'`. If the method name differs in this build, use the actual name in Step 3 and the assertion in Step 1.
- [ ] **Step 1: Write the failing test**
Create `fusion_centralize_billing/tests/test_subscription_cancel.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSubscriptionCancel(TransactionCase):
def setUp(self):
super().setUp()
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.product = self.env['product.product'].sudo().create(
{'name': 'NexaCloud Plan', 'type': 'service',
'recurring_invoice': True, 'list_price': 49.0})
self.svc_a = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaCloud', 'code': 'nexacloud'})
self.svc_b = self.env['fusion.billing.service'].sudo().create(
{'name': 'Other', 'code': 'other'})
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
res = self.svc_a._api_create_subscription({
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
self.sub = self.env['sale.order'].browse(res['subscription_id'])
def test_cancel_closes_subscription(self):
self.assertEqual(self.sub.subscription_state, '3_progress')
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_is_idempotent(self):
self.svc_a._api_cancel_subscription(str(self.sub.id))
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_unknown_subscription_rejected(self):
res = self.svc_a._api_cancel_subscription('999999999')
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
def test_cancel_cross_service_rejected(self):
# svc_b is not linked to the customer that owns self.sub
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertEqual(self.sub.subscription_state, '3_progress')
def test_cancel_missing_id_rejected(self):
res = self.svc_a._api_cancel_subscription('')
self.assertEqual(res['status'], 'error')
```
Append to `fusion_centralize_billing/tests/__init__.py`:
```python
from . import test_subscription_cancel
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
```
Expected: FAIL — `AttributeError: 'fusion.billing.service' object has no attribute '_api_cancel_subscription'`.
- [ ] **Step 3: Implement the method**
In `fusion_centralize_billing/models/service.py`, add immediately after `_api_create_subscription`:
```python
def _api_cancel_subscription(self, external_ref):
"""Cancel (close) the subscription identified by ``external_ref``.
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
exist, be a subscription, and belong to a customer THIS service is linked
to. Idempotent — closing an already-churned subscription returns ok.
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
"""
self.ensure_one()
if external_ref in (None, ''):
return {'status': 'error', 'error': 'subscription id required'}
sub = self._fc_resolve_subscription(external_ref)
linked_partners = self.account_link_ids.mapped('partner_id')
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}
if sub.subscription_state != '6_churn':
sub.set_close()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
```
Expected: PASS — 5 tests, 0 failures. (If `set_close()` was a different name in Step 0, use that name here and re-run.)
- [ ] **Step 5: Commit**
```bash
git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_subscription_cancel.py fusion_centralize_billing/tests/__init__.py
git commit -m "feat(billing): add _api_cancel_subscription (close sub, service-scoped authz)"
```
---
### Task 2: `DELETE /subscriptions/<ref>` route
**Files:**
- Modify: `fusion_centralize_billing/controllers/api.py` (add route after `post_subscription`, ~line 95)
- Modify: `fusion_centralize_billing/tests/test_subscription_cancel.py` (add an HTTP-layer test)
- [ ] **Step 1: Write the failing test (HTTP layer)**
Append to `tests/test_subscription_cancel.py` a class that exercises the route through Odoo's test client. Add the import at the top of the file:
```python
from odoo.tests import HttpCase
```
Then append:
```python
@tagged('post_install', '-at_install')
class TestSubscriptionCancelHttp(HttpCase):
def setUp(self):
super().setUp()
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.product = self.env['product.product'].sudo().create(
{'name': 'NexaCloud Plan', 'type': 'service',
'recurring_invoice': True, 'list_price': 49.0})
self.svc = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaCloud', 'code': 'nexacloud'})
self.raw_key = self.svc.action_generate_api_key()
self.svc._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
res = self.svc._api_create_subscription({
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
self.sub_id = res['subscription_id']
self.env.cr.commit()
self.addCleanup(self._cleanup)
def _cleanup(self):
self.env['sale.order'].browse(self.sub_id).sudo().unlink()
def test_delete_requires_auth(self):
resp = self.url_open(
"/api/billing/v1/subscriptions/%s" % self.sub_id,
method='DELETE')
self.assertEqual(resp.status_code, 401)
def test_delete_cancels_with_valid_key(self):
resp = self.url_open(
"/api/billing/v1/subscriptions/%s" % self.sub_id,
method='DELETE',
headers={'Authorization': 'Bearer %s' % self.raw_key})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['subscription_state'], '6_churn')
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
```
Expected: FAIL — the DELETE route returns 404 (route not registered) so the assertions fail.
- [ ] **Step 3: Implement the route**
In `fusion_centralize_billing/controllers/api.py`, add after `post_subscription`:
```python
@http.route(f"{API_BASE}/subscriptions/<sub_ref>", type="http", auth="none",
methods=["DELETE"], csrf=False)
def delete_subscription(self, sub_ref, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
result = service._api_cancel_subscription(sub_ref)
if result.get("status") == "error":
status = 404 if result.get("error") == "unknown subscription" else 400
return self._json(result, status=status)
return self._json(result)
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
```
Expected: PASS — 2 tests, 0 failures.
- [ ] **Step 5: Commit**
```bash
git add fusion_centralize_billing/controllers/api.py fusion_centralize_billing/tests/test_subscription_cancel.py
git commit -m "feat(billing): DELETE /api/billing/v1/subscriptions/<ref> cancel route"
```
---
## Self-review
- **Spec coverage:** §4.1.3 "add subscription cancel (`DELETE /subscriptions/:id`)" → Tasks 1+2. ✔
- **Placeholder scan:** none — all code is concrete; Step 0 verifies the one Odoo-internal name (`set_close`) against the live container instead of assuming.
- **Type consistency:** `_api_cancel_subscription` returns the same `{'status','subscription_id','subscription_state'}` shape as `_api_create_subscription`; error shape matches `_api_record_usage` (`{'status':'error','error':...}`); resolver reused (`_fc_resolve_subscription`) so cross-service rejection is identical to `/usage`. ✔
- **Authorization parity:** cancel uses the exact `not sub.exists() or not sub.is_subscription or sub.partner_id not in linked_partners` guard as `_api_record_usage`. ✔

View File

@@ -0,0 +1,737 @@
# Service Booking Wizard + Auto-Quote — Implementation Plan (Plan 2 of 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** A polished OWL "Book a Service" wizard that captures the client (incl. new clients inline), books the technician task, prices the call-out from the Plan-1 rate table, and auto-creates a draft repair Sale Order — with a correct, consistent timezone conversion.
**Architecture:** TZ fix in `fusion_tasks`; everything else in `fusion_claims` (it owns the SO + the `technician.task` SO-link + Plan 1's rates). A server method `action_book_from_wizard` does the work (contact + task + SO); an OWL client action is the UI and calls it through two `jsonrpc` controller routes. Pricing is read from `fusion.service.rate` (Plan 1) — never hardcoded.
**Tech Stack:** Odoo 19 (ORM, `TransactionCase`), OWL (`@odoo/owl`, standalone `rpc` from `@web/core/network/rpc`, `registry.category("actions")`), SCSS branching on `$o-webclient-color-scheme`.
**Depends on:** Plan 1 (`fusion.service.rate` + `get_callout`/`get_rate`). **Spec:** `…/specs/2026-06-03-technician-service-booking-design.md`. **Mockup (UI source of truth):** `…/mockups/technician-booking-wizard.html`.
---
## ⚠️ Testing reality
`fusion_claims` is Enterprise-only → not installable on local Community. `TransactionCase` tests run on a **Westin Enterprise clone** (see Plan 1's testing note + repo `CLAUDE.md`). OWL UI has **no unit test** — verify by manual smoke on the clone browser. Pure-Python tasks (14) are TDD; the OWL task (5) is build-then-smoke.
**Pre-flight (rule #1 — never code from memory):** before Tasks 1, 3, 4, read the real signatures:
```bash
docker exec odoo-dev-app sed -n '760,800p;975,1010p;2725,2775p' \
/mnt/extra-addons/fusion_tasks/models/technician_task.py
```
Confirm `_get_local_tz`, `_compute_datetimes`/inverses, `_calculate_travel_time(origin_lat, origin_lng)` (sets `travel_distance_km`), and `_quick_travel_time`.
---
## File structure
| File | Responsibility |
|---|---|
| `fusion_tasks/models/technician_task.py` *(modify ~781-798)* | tz-consistent inverses |
| `fusion_tasks/tests/test_task_tz.py` + `__init__.py` *(create)* | tz round-trip test |
| `fusion_claims/models/technician_task.py` *(modify)* | relax `_check_order_link`; add `x_fc_service_call_type`; pricing resolver; SO builder; `action_book_from_wizard` |
| `fusion_claims/models/sale_order.py` *(modify)* | `x_fc_is_service_repair` flag |
| `fusion_claims/data/service_repair_data.xml` *(create)* | "Service Repair" CRM tag |
| `fusion_claims/controllers/__init__.py` + `controllers/service_booking.py` *(create)* | `jsonrpc` refdata + submit routes |
| `fusion_claims/__init__.py` *(modify)* | import controllers |
| `fusion_claims/static/src/js/service_booking/service_booking.js` *(create)* | OWL client action |
| `fusion_claims/static/src/xml/service_booking.xml` *(create)* | OWL template (ported from mockup) |
| `fusion_claims/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss` *(create)* | styles, dark/light |
| `fusion_claims/views/service_booking_action.xml` *(create)* | `ir.actions.client` + menu |
| `fusion_claims/__manifest__.py` *(modify)* | assets + data + version |
| `fusion_claims/tests/test_service_booking.py` *(create)* | resolver, SO builder, booking method |
---
## Task 1: Timezone-consistent inverses (`fusion_tasks`)
**Files:** Modify `fusion_tasks/models/technician_task.py`; create `fusion_tasks/tests/test_task_tz.py` (+ `tests/__init__.py` if absent).
- [ ] **Step 1: Write the failing test**
Create `fusion_tasks/tests/test_task_tz.py`:
```python
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestTaskTz(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.tz = 'America/Toronto' # UTC-4 in summer
cls.task = cls.env['fusion.technician.task'].create({
'scheduled_date': date(2026, 6, 3),
'time_start': 9.0, 'time_end': 10.0,
})
def test_local_to_utc_compute(self):
# 9:00 local Toronto (DST, -4) -> 13:00 UTC stored
self.assertEqual(self.task.datetime_start.hour, 13)
def test_inverse_round_trips_with_same_tz(self):
# writing datetime_start back must recover the same local time_start
self.task.datetime_start = self.task.datetime_start # force inverse
self.task.flush_recordset(['datetime_start'])
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
```
Register in `fusion_tasks/tests/__init__.py` (create if missing):
```python
from . import test_task_tz
```
If `fusion_tasks/tests/` doesn't exist, also add `'fusion_tasks/tests'` is auto-discovered — just ensure the `__init__.py` exists.
- [ ] **Step 2: Run — verify it fails** (on the clone, `--test-tags /fusion_tasks.TestTaskTz`). Expected: `test_inverse_round_trips` FAILS if user.tz ≠ company-calendar tz, or passes spuriously if they're equal — set the company `resource_calendar_id.tz` to `America/Toronto` in `setUpClass` too if needed to expose the mismatch.
- [ ] **Step 3: Fix the inverses**
In `fusion_tasks/models/technician_task.py`, the two inverse methods currently use `pytz.timezone(self.env.user.tz or 'UTC')`. Change **both** to use the same resolver as `_compute_datetimes`:
```python
def _inverse_datetime_start(self):
"""When datetime_start changes (calendar drag), update date + time. Uses the
SAME tz resolver as _compute_datetimes so the round-trip is consistent."""
import pytz
user_tz = self._get_local_tz()
for task in self:
if task.datetime_start:
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
task.scheduled_date = local_dt.date()
task.time_start = local_dt.hour + local_dt.minute / 60.0
def _inverse_datetime_end(self):
import pytz
user_tz = self._get_local_tz()
for task in self:
if task.datetime_end:
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
task.time_end = local_dt.hour + local_dt.minute / 60.0
```
(Only the `user_tz = …` line changes in each — from `pytz.timezone(self.env.user.tz or 'UTC')` to `self._get_local_tz()`.)
- [ ] **Step 4: Run — verify it passes** (`--test-tags /fusion_tasks.TestTaskTz`). Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add fusion_tasks/models/technician_task.py fusion_tasks/tests/test_task_tz.py fusion_tasks/tests/__init__.py
git commit -m "fix(fusion_tasks): make datetime inverses use the same tz resolver as compute"
```
---
## Task 2: Relax SO constraint + repair-SO identity (`fusion_claims`)
**Files:** Modify `fusion_claims/models/technician_task.py`, `fusion_claims/models/sale_order.py`; create `fusion_claims/data/service_repair_data.xml`; modify `__manifest__.py`; test in `fusion_claims/tests/test_service_booking.py`.
- [ ] **Step 1: Write the failing test**
Create `fusion_claims/tests/test_service_booking.py`:
```python
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceBooking(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Task = cls.env['fusion.technician.task']
def test_task_without_order_is_allowed(self):
# repair for a brand-new client: no SO/PO must NOT raise
t = self.Task.create({'task_type': 'repair', 'scheduled_date': date(2026, 6, 3)})
self.assertTrue(t.id)
def test_sale_order_has_service_repair_flag(self):
so = self.env['sale.order'].new({})
self.assertIn('x_fc_is_service_repair', so._fields)
```
Register in `fusion_claims/tests/__init__.py` (append): `from . import test_service_booking`.
- [ ] **Step 2: Run — verify it fails** (`--test-tags /fusion_claims.TestServiceBooking`). Expected: `test_task_without_order_is_allowed` FAILS with the ValidationError from `_check_order_link`; `test_sale_order_has_service_repair_flag` FAILS (field missing).
- [ ] **Step 3: Relax the constraint**
In `fusion_claims/models/technician_task.py`, replace the body of `_check_order_link` so it no longer requires an order (the wizard auto-creates one; in-shop/walk-in legitimately have none):
```python
@api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self):
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
# walk-in tasks may have none. No order link is required anymore.
return
```
(Keep the method as a no-op rather than deleting it, so any external `super()`/override chains stay intact.)
- [ ] **Step 4: Add the repair flag + tag**
In `fusion_claims/models/sale_order.py`, add to the `sale.order` class:
```python
x_fc_is_service_repair = fields.Boolean(
string='Service Repair', copy=False,
help='Auto-created from the technician service booking wizard.',
)
```
Create `fusion_claims/data/service_repair_data.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="tag_service_repair" model="crm.tag">
<field name="name">Service Repair</field>
</record>
</data>
</odoo>
```
Register it in `__manifest__.py` `data` (after the service-rate data from Plan 1):
```python
'data/service_repair_data.xml',
```
> `crm.tag` requires the `sale_crm`/`crm` dependency. If `fusion_claims` doesn't pull `crm`, use `sale.order.tag` — verify which tag model exists: `docker exec odoo-dev-app odoo shell -d westin-v19-ratetest -c "print('crm.tag' in env, 'sale.order' in env)"`. Default to `crm.tag` (Westin has CRM); fall back to skipping the tag and relying on the boolean flag if neither is clean.
- [ ] **Step 5: Run — verify it passes.** Expected: both tests PASS.
- [ ] **Step 6: Commit**
```bash
git add fusion_claims/models/technician_task.py fusion_claims/models/sale_order.py \
fusion_claims/data/service_repair_data.xml fusion_claims/__manifest__.py \
fusion_claims/tests/test_service_booking.py fusion_claims/tests/__init__.py
git commit -m "feat(fusion_claims): allow order-less tasks + service-repair SO flag/tag"
```
---
## Task 3: `x_fc_service_call_type` + pricing resolver + SO builder (`fusion_claims`)
**Files:** Modify `fusion_claims/models/technician_task.py`; test in `test_service_booking.py`.
- [ ] **Step 1: Write the failing test** (append to `TestServiceBooking`):
```python
def test_resolve_service_lines_standard_rush(self):
Task = self.Task
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
# call-out $120 + per-km line qty 20 @ $0.70
callout = [l for l in lines if l['price_unit'] == 120.0]
per_km = [l for l in lines if l['name_is_km']]
self.assertTrue(callout)
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
self.assertEqual(per_km[0]['price_unit'], 0.70)
def test_resolve_service_lines_in_shop_empty_callout(self):
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
self.assertEqual(lines, [])
def test_build_service_so(self):
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
self.assertEqual(so.state, 'draft')
self.assertTrue(so.x_fc_is_service_repair)
self.assertEqual(so.partner_id, partner)
self.assertEqual(so.order_line[0].price_unit, 95.0)
```
- [ ] **Step 2: Run — verify it fails** (methods undefined).
- [ ] **Step 3: Add the field + resolver + builder**
In `fusion_claims/models/technician_task.py`, add the field to the class:
```python
x_fc_service_call_type = fields.Char(
string='Service Call Type',
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
)
```
Add these methods (model methods; rely on Plan 1's `fusion.service.rate`):
```python
@api.model
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
"""Return a list of sale.order.line vals dicts for a service booking,
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
Rate = self.env['fusion.service.rate']
lines = []
callout = Rate.get_callout(category, timing, in_shop=in_shop)
if not callout:
return lines
lines.append({
'product_id': callout.product_id.id,
'name': callout.name,
'product_uom_qty': 1.0,
'price_unit': callout.price,
'name_is_km': False,
})
if callout.adds_per_km and distance_km:
per_km = Rate.get_rate('per_km')
if per_km:
lines.append({
'product_id': per_km.product_id.id,
'name': '%s%.1f km × 2-way' % (per_km.name, distance_km),
'product_uom_qty': round(distance_km * 2.0, 1),
'price_unit': per_km.price,
'name_is_km': True,
})
return lines
@api.model
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines."""
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
so_vals = {
'partner_id': partner.id,
'x_fc_is_service_repair': True,
'order_line': order_lines,
}
tag = self.env.ref('fusion_claims.tag_service_repair', raise_if_not_found=False)
if tag and 'tag_ids' in self.env['sale.order']._fields:
so_vals['tag_ids'] = [(4, tag.id)]
return self.env['sale.order'].create(so_vals)
```
> The `name_is_km` key is a test-only marker stripped before create. If `sale.order` has no `tag_ids` (no CRM), the guard skips the tag.
- [ ] **Step 4: Run — verify it passes.**
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/models/technician_task.py fusion_claims/tests/test_service_booking.py
git commit -m "feat(fusion_claims): service pricing resolver + draft-SO builder from rate table"
```
---
## Task 4: `action_book_from_wizard` + controller routes (`fusion_claims`)
**Files:** Modify `fusion_claims/models/technician_task.py`; create `fusion_claims/controllers/__init__.py`, `controllers/service_booking.py`; modify `fusion_claims/__init__.py`; test in `test_service_booking.py`.
- [ ] **Step 1: Write the failing test** (append):
```python
def test_action_book_creates_contact_task_and_so(self):
payload = {
'cust_mode': 'new',
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
'street': '88 Bloor St E', 'city': 'Toronto'},
'category': 'standard', 'timing': 'normal', 'in_shop': False,
'device': 'scooter', 'issue': "won't power on",
'date': '2026-06-03', 'time_start': 9.0, 'duration_hr': 1.0,
'technician_id': False, 'description': 'check battery',
}
res = self.Task.action_book_from_wizard(payload)
self.assertTrue(res['task_id'] and res['order_id'])
task = self.Task.browse(res['task_id'])
self.assertEqual(task.sale_order_id.id, res['order_id'])
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
self.assertTrue(partner)
```
- [ ] **Step 2: Run — verify it fails.**
- [ ] **Step 3: Implement `action_book_from_wizard`**
Add to `fusion_claims/models/technician_task.py` (read the travel method first — pre-flight). Distance: create the task, run its travel calc to populate `travel_distance_km`, read it for the per-km line, then attach the SO:
```python
@api.model
def action_book_from_wizard(self, payload):
"""Single entry point for the OWL booking wizard:
resolve/create contact -> create task -> compute distance -> build SO -> link."""
Partner = self.env['res.partner']
# 1. contact
cust = payload.get('customer') or {}
if payload.get('cust_mode') == 'new':
partner = False
email = (cust.get('email') or '').strip()
phone = (cust.get('phone') or '').strip()
if email:
partner = Partner.search([('email', '=ilike', email)], limit=1)
if not partner and phone:
partner = Partner.search([('phone', '=', phone)], limit=1)
if not partner:
partner = Partner.create({
'name': cust.get('name') or 'Walk-in',
'phone': phone or False, 'email': email or False,
'street': cust.get('street') or False, 'city': cust.get('city') or False,
})
else:
partner = Partner.browse(int(payload.get('partner_id'))) if payload.get('partner_id') else Partner
category = payload.get('category', 'standard')
timing = payload.get('timing', 'normal')
in_shop = bool(payload.get('in_shop'))
# 2. task
dur = float(payload.get('duration_hr') or 1.0)
t_start = float(payload.get('time_start') or 9.0)
task_vals = {
'task_type': 'repair',
'scheduled_date': payload.get('date'),
'time_start': t_start, 'time_end': t_start + dur, 'duration_hours': dur,
'in_store': in_shop,
'x_fc_service_call_type': '%s_%s' % (category, timing),
'description': payload.get('description') or payload.get('issue') or '',
}
if payload.get('technician_id'):
task_vals['technician_id'] = int(payload['technician_id'])
if partner:
task_vals['client_name'] = partner.name
task_vals['client_phone'] = partner.phone or False
task = self.create(task_vals)
# 3. distance (km) for per-km, if the rate adds it and the job has a location
distance_km = 0.0
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
try:
task._calculate_travel_time(task.address_lat, task.address_lng) # sets travel_distance_km
distance_km = task.travel_distance_km or 0.0
except Exception:
distance_km = 0.0
# 4. SO + link
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
if order:
task.sale_order_id = order.id
return {'task_id': task.id, 'order_id': order.id if order else False}
```
> Verify field names against the model during the pre-flight read: `in_store` vs `in_shop`, `client_name`/`client_phone`, `address_lat`/`address_lng`, `technician_id`. Adjust the vals keys to the real field names (the screenshot shows In-Store, Client Name/Phone, Task Address). If `_calculate_travel_time` needs a different origin, pass the shop/technician start coords instead.
- [ ] **Step 4: Create the controller**
Create `fusion_claims/controllers/__init__.py`:
```python
from . import service_booking
```
Create `fusion_claims/controllers/service_booking.py`:
```python
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
class ServiceBookingController(http.Controller):
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
def refdata(self, **kw):
env = request.env
techs = env['res.users'].search([('x_fc_is_field_staff', '=', True)]) \
if 'x_fc_is_field_staff' in env['res.users']._fields else env['res.users'].search([])
rates = env['fusion.service.rate'].search([('rate_kind', '=', 'callout'), ('active', '=', True)])
per_km = env['fusion.service.rate'].get_rate('per_km')
def labour(code):
r = env['fusion.service.rate'].get_rate(code)
return r.price if r else 0.0
return {
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
'callout_rates': [{
'code': r.code, 'category': r.category, 'timing': r.timing,
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
} for r in rates],
'per_km': per_km.price if per_km else 0.70,
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
'lift': labour('labour_lift')},
}
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
def submit(self, payload=None, **kw):
try:
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
except Exception as e:
return {'error': str(e)}
```
Modify `fusion_claims/__init__.py` (append):
```python
from . import controllers
```
- [ ] **Step 5: Run — verify it passes** (`--test-tags /fusion_claims.TestServiceBooking`). Also `pyflakes` the controller: `docker exec odoo-dev-app python3 -m pyflakes /mnt/extra-addons/fusion_claims/controllers/service_booking.py`.
- [ ] **Step 6: Commit**
```bash
git add fusion_claims/models/technician_task.py fusion_claims/controllers/ fusion_claims/__init__.py fusion_claims/tests/test_service_booking.py
git commit -m "feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes"
```
---
## Task 5: OWL booking wizard + SCSS (`fusion_claims`)
**Files:** create `static/src/js/service_booking/service_booking.js`, `static/src/xml/service_booking.xml`, `static/src/scss/_service_booking_tokens.scss`, `static/src/scss/service_booking.scss`; modify `__manifest__.py` (assets). **No unit test — manual smoke.**
- [ ] **Step 1: Write the OWL component**
Create `fusion_claims/static/src/js/service_booking/service_booking.js`:
```javascript
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class ServiceBookingWizard extends Component {
static template = "fusion_claims.ServiceBookingWizard";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
custMode: "existing", customer: {name:"",phone:"",email:"",street:"",unit:"",buzz:"",city:""},
partnerId: false, soSearch: "",
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
warranty: false, pod: false, emailConfirm: true, googleReview: true,
description: "", materials: "",
technicians: [], calloutRates: [], perKm: 0.70,
labour: {onsite:85, inshop:75, lift:110}, distanceKm: 13, saving: false,
});
onWillStart(async () => {
const r = await rpc("/fusion_claims/service_booking/refdata", {});
Object.assign(this.state, {
technicians: r.technicians, calloutRates: r.callout_rates,
perKm: r.per_km, labour: r.labour,
});
});
}
get callout() {
if (this.state.inShop) return null;
return this.state.calloutRates.find(
r => r.category === this.state.category && r.timing === this.state.timing) || null;
}
get labourRate() {
if (this.state.inShop) return this.state.labour.inshop;
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
}
get estimate() {
const c = this.callout;
const callout = c ? c.price : 0;
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
const billHr = Math.max(0, this.state.durationHr - incl);
const labour = billHr * this.labourRate;
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
}
get endLabel() {
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
}
onDevice(ev) { this.state.device = ev.target.value; this.state.category = ev.target.value === "lift" ? "lift" : "standard"; }
setCust(m) { this.state.custMode = m; }
setTiming(t) { this.state.timing = t; }
setAmpm(v) { this.state.ampm = v; }
toggleInShop() { this.state.inShop = !this.state.inShop; }
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
async submit() {
if (this.state.saving) return;
const s = this.state;
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
this.notification.add("Client name and phone are required.", { type: "danger" }); return;
}
s.saving = true;
const payload = {
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
email_confirm: s.emailConfirm, google_review: s.googleReview,
description: s.description, materials: s.materials,
};
try {
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
this.action.doAction({
type: "ir.actions.act_window", res_model: "fusion.technician.task",
res_id: res.task_id, views: [[false, "form"]], target: "current",
});
} catch (e) {
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
s.saving = false;
}
}
}
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
```
- [ ] **Step 2: Write the OWL template** — port the mockup
Create `fusion_claims/static/src/xml/service_booking.xml` with `<t t-name="fusion_claims.ServiceBookingWizard">`. **Port each section from the mockup** (`docs/superpowers/mockups/technician-booking-wizard.html`) converting static HTML → OWL bindings, per this exact mapping:
| Mockup element | OWL binding |
|---|---|
| `class="theme-btn"` | *remove* — Odoo handles dark/light via the bundle (Step 4) |
| Customer `Existing/New` seg buttons | `t-att-class="{on: state.custMode==='existing'}"` + `t-on-click="() => setCust('existing')"` |
| New-client inputs | `t-model="state.customer.name"` etc. (name, phone, email, street, unit, buzz, city) |
| `<select id="device">` | `t-on-change="onDevice"` (options: scooter/powerchair/wheelchair→standard, stairlift/lift→lift, …) |
| `<select id="callType">` | render from `state.calloutRates` with `t-foreach`; bind selection to category+timing |
| timing seg | `t-on-click``setTiming('normal'|'rush'|'afterhours')` |
| `feeAmt` / `eCall`/`eLab`/`eKm`/`eTotal` | `t-esc="estimate.callout"` etc. (format with a `fmt(n)` helper or `t-out`) |
| in-shop switch | `t-att-class="{on: state.inShop}"` + `t-on-click="toggleInShop"` |
| AM/PM buttons | `t-on-click``setAmpm('AM'|'PM')`; hour/minute `t-model.number` |
| `endlbl` | `t-esc="endLabel"` |
| technician `<select>` | `t-foreach="state.technicians"` + `t-model.number="state.technicianId"` |
| switches (warranty/pod/email/review) | `t-att-class="{on: state.warranty}"` + `t-on-click="() => state.warranty = !state.warranty"` |
| footer `Book & Create SO` | `t-on-click="submit"` `t-att-disabled="state.saving"` |
Keep the mockup's class names so the SCSS (Step 3) applies unchanged. Wrap the root in `<div class="o_service_booking">…</div>`.
- [ ] **Step 3: Port the SCSS (dark/light)**
Create `fusion_claims/static/src/scss/_service_booking_tokens.scss` — the mockup's `:root`/`[data-theme]` token values, converted to the repo's compile-time branch (per `CLAUDE.md` dark-mode rule):
```scss
$o-webclient-color-scheme: bright !default;
$_page:#eef0f3; $_panel:#e6e9ed; $_card:#ffffff; $_border:#d8dadd; $_text:#1f2430;
$_muted:#6b7280; $_field:#ffffff; $_money:#0f7d4e; $_money-soft:#e7f6ee; // …copy the rest from the mockup :root
@if $o-webclient-color-scheme == dark {
$_page:#14161b !global; $_panel:#1b1e24 !global; $_card:#22262d !global; $_border:#343a42 !global;
$_text:#e7eaef !global; $_muted:#9aa3af !global; $_field:#1a1d23 !global;
$_money:#34d27f !global; $_money-soft:#15281f !global; // …copy the dark values from the mockup [data-theme="dark"]
}
.o_service_booking {
--sb-page:#{$_page}; --sb-panel:#{$_panel}; --sb-card:#{$_card}; --sb-border:#{$_border};
--sb-text:#{$_text}; --sb-muted:#{$_muted}; --sb-field:#{$_field};
--sb-money:#{$_money}; --sb-money-soft:#{$_money-soft}; /* …rest */
}
```
Create `fusion_claims/static/src/scss/service_booking.scss` — the mockup's component CSS, scoped under `.o_service_booking` and using the `--sb-*` vars instead of the mockup's `--page` etc. (mechanical rename). Drop the `.theme-btn` rule.
- [ ] **Step 4: Register assets** in `__manifest__.py`:
```python
'assets': {
'web.assets_backend': [
# … existing entries …
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
'fusion_claims/static/src/js/service_booking/service_booking.js',
'fusion_claims/static/src/xml/service_booking.xml',
],
'web.assets_web_dark': [
# dark bundle recompiles the same tokens with the dark scheme
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
],
},
```
- [ ] **Step 5: Smoke (manual, on the clone)**
`-u fusion_claims`, hard-refresh. Trigger the action (Task 6) → the wizard renders; toggle a user dark-mode profile to confirm the dark bundle; book a new client → task form opens, draft SO exists with the right call-out line.
- [ ] **Step 6: Commit**
```bash
git add fusion_claims/static/ fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): OWL service-booking wizard + dark/light SCSS"
```
---
## Task 6: Entry point + version bump
**Files:** create `fusion_claims/views/service_booking_action.xml`; modify `__manifest__.py`.
- [ ] **Step 1: Create the client action + menu**
Create `fusion_claims/views/service_booking_action.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_service_booking_wizard" model="ir.actions.client">
<field name="name">Book a Service</field>
<field name="tag">fusion_claims.service_booking</field>
</record>
<menuitem id="menu_service_booking"
name="Book a Service"
parent="PARENT_MENU_XMLID"
action="action_service_booking_wizard"
sequence="1"/>
</odoo>
```
Use the same Field-Service menu parent identified in Plan 1 Task 4 Step 2 (e.g. the technician-task app menu). Register in `__manifest__.py` `data` after the views.
- [ ] **Step 2: Bump version** in `__manifest__.py` (e.g. `19.0.9.3.0``19.0.9.4.0`).
- [ ] **Step 3: Full upgrade + all tests** (clone): `--test-tags /fusion_claims,/fusion_tasks`. Expected: all PASS.
- [ ] **Step 4: End-to-end smoke (clone browser)***Book a Service* menu → existing customer path (SO search prefill) and new-client path; confirm task + draft repair SO + correct call-out; rush/after-hours adds the per-km line; reschedule lands at the right local time (Task 1).
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/views/service_booking_action.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): Book a Service entry point + version bump"
```
---
## Self-Review (done while writing)
- **Spec coverage:** tz fix §8 ✓ (T1); constraint relax §6.3 ✓ (T2); repair-SO flag/tag §6.3 ✓ (T2); resolver reads rate table §7 ✓ (T3); SO builder + per-km §7 ✓ (T3); `action_book_from_wizard` (contact→task→distance→SO) §5 ✓ (T4); OWL wizard + dark/light SCSS §5 ✓ (T5); entry point §11 ✓ (T6). Estimate-as-UI-only §9 ✓ (component `estimate` getter, not written to SO).
- **Placeholders:** none for logic. Two deliberate lookups — the menu parent xmlid (T6/Plan-1) and the field-name verification in T4 (real "read the model first" per rule #1), both concrete actions, not vague TODOs. The template/SCSS port references the **mockup** (a complete existing artifact) with an explicit element→binding mapping — concrete, not "similar to".
- **Type/name consistency:** `_resolve_service_lines(category, timing, in_shop, distance_km)` and `_build_service_so(partner, category, timing, in_shop, distance_km)` match across T3 tests, T4 caller, and the controller. Rate codes (`callout_standard_rush`, `per_km`, `labour_onsite/inshop/lift`) match Plan 1's seed. Controller routes `/fusion_claims/service_booking/{refdata,submit}` match the OWL `rpc()` calls. `action_book_from_wizard` return shape `{task_id, order_id}` matches the component's `res.task_id`.
- **Flagged for execution:** verify real task field names in T4 (`in_store`/`client_name`/`address_lat`…) and the `crm.tag` vs `sale.order` tag model in T2 — both have explicit verify steps.
---
## Execution Handoff
Both plans are written:
- **Plan 1** — `…/plans/2026-06-03-service-rates-foundation-plan.md`
- **Plan 2** — this file.
**Order:** Plan 1 → Plan 2 (Plan 2 consumes Plan 1's rate table). First move the work to a dedicated branch: `git checkout -b claude/technician-service-booking` (off `main`, *not* the fusion_schedule-fix branch).
Two execution options (per the writing-plans skill):
1. **Subagent-Driven (recommended)** — a fresh subagent per task, reviewed between tasks. Best given the Enterprise-clone test loop.
2. **Inline Execution** — execute tasks in this session with checkpoints.
**Caveat:** verification requires the Westin Enterprise clone (no local Community install). Plan to run the test/smoke steps there.

View File

@@ -0,0 +1,718 @@
# Service Rates Foundation — Implementation Plan (Plan 1 of 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an editable `fusion.service.rate` table (the Westin rate card, admin-managed from a **Service Rates** menu) that the booking wizard (Plan 2) will price from.
**Architecture:** A new `fusion.service.rate` model in `fusion_claims` (owns SO + products). Each row holds an editable `price` and links to a `product.product` (for SO-line description/tax/account). Seeded once (`noupdate=1`) from the rate card; admins own it thereafter. Two resolver methods (`get_callout`, `get_rate`) are the read API for Plan 2.
**Tech Stack:** Odoo 19 (Python ORM, declarative `models.Constraint`, XML data/views, `TransactionCase`).
**Spec:** `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md` (§3, §6.1).
---
## ⚠️ Testing reality (read before executing)
`fusion_claims` is **Enterprise-only** (depends `ai`) → it **cannot install on local `odoo-modsdev` (Community)**. Tests here are real `TransactionCase` tests but they run on a **Westin Enterprise clone** (see the repo `CLAUDE.md` *Westin Prod — Clone-Verify* section). Canonical run on the clone host:
```bash
docker exec odoo-dev-app odoo -d westin-v19-ratetest --test-enable --test-tags /fusion_claims \
-u fusion_claims --stop-after-init --no-http --workers 0 --log-level=test \
--db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -60
```
Where a step says "Run the test", it means *on the clone*. If the clone isn't available during a step, verify the logic via `odoo shell -d <clone>` instead and check the box once confirmed. **Do not** attempt `-d modsdev` (it can't install the module).
---
## File structure
| File | Responsibility |
|---|---|
| `fusion_claims/models/service_rate.py` *(create)* | `fusion.service.rate` model: fields, unique-code constraint, `get_callout` / `get_rate` resolvers |
| `fusion_claims/models/__init__.py` *(modify)* | import `service_rate` |
| `fusion_claims/data/service_rate_products.xml` *(create)* | seed `product.product` service products (one per rate) — `noupdate=1` |
| `fusion_claims/data/service_rate_data.xml` *(create)* | seed `fusion.service.rate` rows linking the products — `noupdate=1` |
| `fusion_claims/views/service_rate_views.xml` *(create)* | list + form + action + **Service Rates** menu |
| `fusion_claims/security/ir.model.access.csv` *(modify)* | ACL: read for users, full for system/managers |
| `fusion_claims/__manifest__.py` *(modify)* | register the 3 new data/view files; bump version |
| `fusion_claims/tests/test_service_rate.py` *(create)* | model + resolver + seed tests |
| `fusion_claims/tests/__init__.py` *(modify)* | import the test |
---
## Task 1: `fusion.service.rate` model + resolvers
**Files:**
- Create: `fusion_claims/models/service_rate.py`
- Modify: `fusion_claims/models/__init__.py`
- Test: `fusion_claims/tests/test_service_rate.py`, `fusion_claims/tests/__init__.py`
- [ ] **Step 1: Write the failing test**
Create `fusion_claims/tests/test_service_rate.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceRate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Rate = cls.env['fusion.service.rate']
cls.product = cls.env['product.product'].create({
'name': 'Test Service Product', 'type': 'service',
})
def _make(self, **kw):
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
vals.update(kw)
return self.Rate.create(vals)
def test_get_callout_matches_category_and_timing(self):
r = self._make(code='callout_standard_normal', category='standard', timing='normal', price=95.0)
self._make(code='callout_lift_normal', category='lift', timing='normal', price=160.0)
self.assertEqual(self.Rate.get_callout('standard', 'normal'), r)
def test_get_callout_in_shop_returns_empty(self):
self._make(code='callout_standard_normal_b')
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
def test_get_rate_by_code(self):
r = self._make(code='per_km', rate_kind='travel', category='na', timing='na', unit='per_km', price=0.70)
self.assertEqual(self.Rate.get_rate('per_km'), r)
def test_code_must_be_unique(self):
self._make(code='dup')
with self.assertRaises(Exception):
self._make(code='dup')
self.env.flush_all()
```
Register it in `fusion_claims/tests/__init__.py` (append):
```python
from . import test_service_rate
```
- [ ] **Step 2: Run the test — verify it fails**
Run (on the clone): the canonical command above with `--test-tags /fusion_claims.TestServiceRate`.
Expected: FAIL — `KeyError: 'fusion.service.rate'` (model does not exist yet).
- [ ] **Step 3: Create the model**
Create `fusion_claims/models/service_rate.py`:
```python
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class FusionServiceRate(models.Model):
_name = 'fusion.service.rate'
_description = 'Field Service Rate'
_order = 'sequence, rate_kind, category, timing'
name = fields.Char(string='Name', required=True)
code = fields.Char(
string='Code', required=True, index=True,
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
)
rate_kind = fields.Selection([
('callout', 'Service Call-out'),
('labour', 'Labour'),
('travel', 'Travel / per-km'),
('delivery', 'Delivery / Pickup'),
('other', 'Other'),
], string='Kind', required=True, default='callout')
category = fields.Selection([
('standard', 'Standard'),
('lift', 'Lift & Elevating'),
('na', 'N/A'),
], string='Category', default='na')
timing = fields.Selection([
('normal', 'Normal'),
('rush', 'Rush'),
('afterhours', 'After-Hours'),
('na', 'N/A'),
], string='Timing', default='na')
in_shop = fields.Boolean(string='In-Shop')
product_id = fields.Many2one(
'product.product', string='Invoice Product', required=True, ondelete='restrict',
help='Product used on the sale-order line (description, tax, income account).',
)
price = fields.Monetary(
string='Rate', required=True, currency_field='currency_id',
help='Editable price used on the SO line and the on-screen estimate.',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit = fields.Selection([
('fixed', 'Flat'),
('per_hour', 'Per hour'),
('per_km', 'Per km'),
], string='Unit', required=True, default='fixed')
adds_per_km = fields.Boolean(
string='Adds per-km travel',
help='Call-outs billed as $X + per-km × 2-way (rush / after-hours).',
)
included_labour_min = fields.Integer(
string='Included labour (min)', default=0,
help='Free labour minutes bundled into a service call (e.g. 30).',
)
active = fields.Boolean(string='Active', default=True)
sequence = fields.Integer(string='Sequence', default=10)
_unique_code = models.Constraint(
'UNIQUE(code)',
'A service-rate code must be unique.',
)
@api.model
def get_callout(self, category, timing, in_shop=False):
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
if in_shop:
return self.browse()
return self.search([
('rate_kind', '=', 'callout'),
('category', '=', category),
('timing', '=', timing),
], limit=1)
@api.model
def get_rate(self, code):
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
return self.search([('code', '=', code)], limit=1)
```
Add to `fusion_claims/models/__init__.py` (append a line near the other imports):
```python
from . import service_rate
```
- [ ] **Step 4: Run the test — verify it passes**
Run (on the clone) with `--test-tags /fusion_claims.TestServiceRate`.
Expected: PASS (4 tests). If `test_code_must_be_unique` errors instead of failing cleanly, the unique constraint is firing — that is the pass condition (it raises).
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/models/service_rate.py fusion_claims/models/__init__.py \
fusion_claims/tests/test_service_rate.py fusion_claims/tests/__init__.py
git commit -m "feat(fusion_claims): add fusion.service.rate model + resolvers"
```
---
## Task 2: Seed the service-rate products
**Files:**
- Create: `fusion_claims/data/service_rate_products.xml`
- Modify: `fusion_claims/__manifest__.py`
Products back each rate row (SO line description/tax/account). UoM: hour for labour, unit for everything else (per-km uses `unit` with qty = km×2 — avoids a custom km UoM). Taxes are **not** set here (matches the existing `LABOR` product convention — taxes applied per-DB by an admin).
- [ ] **Step 1: Create the product seed data**
Create `fusion_claims/data/service_rate_products.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Call-outs (unit) -->
<record id="product_callout_standard_normal" model="product.template">
<field name="name">Service Call — Standard</field>
<field name="default_code">SVC-STD</field>
<field name="type">service</field>
<field name="list_price">95.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_rush" model="product.template">
<field name="name">Service Call — Standard Rush</field>
<field name="default_code">SVC-STD-RUSH</field>
<field name="type">service</field>
<field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_afterhours" model="product.template">
<field name="name">Service Call — Standard After-Hours</field>
<field name="default_code">SVC-STD-AH</field>
<field name="type">service</field>
<field name="list_price">140.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_normal" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating</field>
<field name="default_code">SVC-LIFT</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_rush" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating Rush</field>
<field name="default_code">SVC-LIFT-RUSH</field>
<field name="type">service</field>
<field name="list_price">185.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_afterhours" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating After-Hours</field>
<field name="default_code">SVC-LIFT-AH</field>
<field name="type">service</field>
<field name="list_price">205.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Labour (hour) -->
<record id="product_labour_onsite" model="product.template">
<field name="name">Labour — On-Site</field>
<field name="default_code">LAB-ONSITE</field>
<field name="type">service</field>
<field name="list_price">85.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_lift" model="product.template">
<field name="name">Labour — Lift &amp; Elevating</field>
<field name="default_code">LAB-LIFT</field>
<field name="type">service</field>
<field name="list_price">110.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Travel (unit; qty = km × 2) -->
<record id="product_per_km" model="product.template">
<field name="name">Travel — per km (2-way)</field>
<field name="default_code">SVC-KM</field>
<field name="type">service</field>
<field name="list_price">0.70</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Delivery / pickup (unit) -->
<record id="product_delivery_local" model="product.template">
<field name="name">Delivery / Pickup — Local</field>
<field name="default_code">DEL-LOCAL</field>
<field name="type">service</field><field name="list_price">35.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_outside" model="product.template">
<field name="name">Delivery / Pickup — Outside Local Area</field>
<field name="default_code">DEL-OUT</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_rush" model="product.template">
<field name="name">Rush Pickup / Delivery</field>
<field name="default_code">DEL-RUSH</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_liftchair" model="product.template">
<field name="name">Lift Chair — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-LIFTCHAIR</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_hospitalbed" model="product.template">
<field name="name">Hospital Bed — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-BED</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_stairlift" model="product.template">
<field name="name">Stairlift — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_removal_stairlift" model="product.template">
<field name="name">Stairlift — Removal</field>
<field name="default_code">RMV-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
</data>
</odoo>
```
- [ ] **Step 2: Register in the manifest**
In `fusion_claims/__manifest__.py`, add to the `data` list **immediately after** `'data/product_labor_data.xml'`:
```python
'data/service_rate_products.xml',
```
- [ ] **Step 3: Verify load (on the clone)**
Run: `docker exec odoo-dev-app odoo -d westin-v19-ratetest -u fusion_claims --stop-after-init --no-http --workers 0 --db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -20`
Expected: no error; module upgraded. (No test yet — products are referenced by Task 3's data.)
- [ ] **Step 4: Commit**
```bash
git add fusion_claims/data/service_rate_products.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): seed service-rate products"
```
---
## Task 3: Seed the rate rows
**Files:**
- Create: `fusion_claims/data/service_rate_data.xml`
- Modify: `fusion_claims/__manifest__.py`
- Test: `fusion_claims/tests/test_service_rate.py`
`product.template` external IDs from Task 2 resolve to a `product.product` via `.product_variant_id`. In data XML, reference the variant with `ref="product_callout_standard_normal_product_template"`? No — simplest is to point `product_id` at the template's variant using the template's xmlid is not valid for a `product.product` m2o. Use a tiny Python step instead: a `post_init`-style noupdate is awkward for m2o-to-variant. **Approach:** reference the product *variant* created automatically. Odoo creates `product.product` for each template; its xmlid is `<template_xmlid>_product_variant`? It is **not** auto-created. So we set `product_id` by searching on `default_code` in a noupdate `function`. Keep it simple and deterministic:
- [ ] **Step 1: Write the failing test (seed assertions)**
Append to `fusion_claims/tests/test_service_rate.py`:
```python
def test_seeded_callouts_exist(self):
# standard normal $95, lift after-hours $205 are the canonical seeds
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
self.assertEqual(std.price, 95.0)
self.assertEqual(std.rate_kind, 'callout')
self.assertTrue(std.product_id)
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
self.assertEqual(lift_ah.price, 205.0)
self.assertTrue(lift_ah.adds_per_km)
def test_seeded_per_km(self):
km = self.env['fusion.service.rate'].get_rate('per_km')
self.assertTrue(km)
self.assertEqual(km.unit, 'per_km')
self.assertEqual(km.price, 0.70)
```
- [ ] **Step 2: Run — verify it fails**
Run with `--test-tags /fusion_claims.TestServiceRate`.
Expected: FAIL — `ValueError: External ID not found: fusion_claims.rate_callout_standard_normal`.
- [ ] **Step 3: Create the rate seed data**
Create `fusion_claims/data/service_rate_data.xml`. Each rate's `product_id` is set with `eval` that resolves the template's variant at load time:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CALL-OUTS -->
<record id="rate_callout_standard_normal" model="fusion.service.rate">
<field name="name">Standard Service Call</field>
<field name="code">callout_standard_normal</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">95.0</field>
<field name="product_id" ref="product_callout_standard_normal_product_variant"/>
<field name="sequence">10</field>
</record>
<record id="rate_callout_standard_rush" model="fusion.service.rate">
<field name="name">Rush Service Call (Standard)</field>
<field name="code">callout_standard_rush</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
<field name="product_id" ref="product_callout_standard_rush_product_variant"/>
<field name="sequence">11</field>
</record>
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
<field name="name">After-Hours Service Call (Standard)</field>
<field name="code">callout_standard_afterhours</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
<field name="product_id" ref="product_callout_standard_afterhours_product_variant"/>
<field name="sequence">12</field>
</record>
<record id="rate_callout_lift_normal" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Service Call</field>
<field name="code">callout_lift_normal</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">160.0</field>
<field name="product_id" ref="product_callout_lift_normal_product_variant"/>
<field name="sequence">20</field>
</record>
<record id="rate_callout_lift_rush" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Rush Call</field>
<field name="code">callout_lift_rush</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
<field name="product_id" ref="product_callout_lift_rush_product_variant"/>
<field name="sequence">21</field>
</record>
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating After-Hours Call</field>
<field name="code">callout_lift_afterhours</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
<field name="product_id" ref="product_callout_lift_afterhours_product_variant"/>
<field name="sequence">22</field>
</record>
<!-- LABOUR -->
<record id="rate_labour_onsite" model="fusion.service.rate">
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
<field name="rate_kind">labour</field><field name="category">standard</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
<field name="product_id" ref="product_labour_onsite_product_variant"/><field name="sequence">30</field>
</record>
<record id="rate_labour_lift" model="fusion.service.rate">
<field name="name">Labour — Lift &amp; Elevating</field><field name="code">labour_lift</field>
<field name="rate_kind">labour</field><field name="category">lift</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
<field name="product_id" ref="product_labour_lift_product_variant"/><field name="sequence">31</field>
</record>
<record id="rate_labour_inshop" model="fusion.service.rate">
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
<field name="product_id" ref="product_labor_hourly_product_variant"/><field name="sequence">32</field>
</record>
<!-- TRAVEL -->
<record id="rate_per_km" model="fusion.service.rate">
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
<field name="rate_kind">travel</field><field name="category">na</field>
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
<field name="product_id" ref="product_per_km_product_variant"/><field name="sequence">40</field>
</record>
<!-- DELIVERY / PICKUP -->
<record id="rate_delivery_local" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">35.0</field>
<field name="product_id" ref="product_delivery_local_product_variant"/><field name="sequence">50</field>
</record>
<record id="rate_delivery_outside" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">60.0</field>
<field name="product_id" ref="product_delivery_outside_product_variant"/><field name="sequence">51</field>
</record>
<record id="rate_setup_stairlift" model="fusion.service.rate">
<field name="name">Stairlift — Delivery &amp; Set-up</field><field name="code">setup_stairlift</field>
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">300.0</field>
<field name="product_id" ref="product_setup_stairlift_product_variant"/><field name="sequence">52</field>
</record>
</data>
</odoo>
```
> **Note on `_product_variant` refs:** Odoo auto-creates the `product.product` for a single-variant `product.template` and assigns it the external ID `<template_xmlid>_product_variant`. This is the supported way to reference the variant from data XML. (The existing in-shop labour reuses `product_labor_hourly` from `product_labor_data.xml`, hence `product_labor_hourly_product_variant`.) If a `_product_variant` ref ever fails to resolve on your DB, the fallback is to set `product_id` via `eval="obj().env.ref('fusion_claims.product_xxx').product_variant_id.id"` — but try the `_product_variant` ref first.
Register in `fusion_claims/__manifest__.py`, **immediately after** `'data/service_rate_products.xml'`:
```python
'data/service_rate_data.xml',
```
- [ ] **Step 4: Run the test — verify it passes**
Run with `--test-tags /fusion_claims.TestServiceRate` (the `-u fusion_claims` reload loads the seed first).
Expected: PASS (all tests incl. `test_seeded_callouts_exist`, `test_seeded_per_km`).
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/data/service_rate_data.xml fusion_claims/__manifest__.py fusion_claims/tests/test_service_rate.py
git commit -m "feat(fusion_claims): seed service-rate rows from the rate card"
```
---
## Task 4: Security ACL + Service Rates views & menu
**Files:**
- Modify: `fusion_claims/security/ir.model.access.csv`
- Create: `fusion_claims/views/service_rate_views.xml`
- Modify: `fusion_claims/__manifest__.py`
- [ ] **Step 1: Add the ACL rows**
Append to `fusion_claims/security/ir.model.access.csv`:
```csv
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
access_fusion_service_rate_manager,fusion.service.rate.manager,model_fusion_service_rate,base.group_system,1,1,1,1
```
(Users read rates — the wizard needs that; system/managers edit. If `fusion_claims` defines a sales-manager group, swap the second row's group for it during review.)
- [ ] **Step 2: Find the parent menu**
Run: `grep -n "menuitem" fusion_claims/views/*.xml fusion_tasks/views/*.xml | grep -i "id=" | head -40`
Pick the appropriate Configuration/root menu for "Service Rates" (e.g. the fusion_claims app root or a Field-Service config menu). Record its full xmlid (e.g. `fusion_claims.menu_fusion_claims_config` or `sale.menu_sale_config`). Use it as `parent=` in Step 3.
- [ ] **Step 3: Create the views**
Create `fusion_claims/views/service_rate_views.xml` (replace `PARENT_MENU_XMLID` with the id found in Step 2):
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_service_rate_view_list" model="ir.ui.view">
<field name="name">fusion.service.rate.list</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<list string="Service Rates" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="rate_kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
<field name="unit"/>
<field name="price"/>
<field name="currency_id" column_invisible="1"/>
<field name="adds_per_km"/>
<field name="product_id"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="fusion_service_rate_view_form" model="ir.ui.view">
<field name="name">fusion.service.rate.form</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<form string="Service Rate">
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Standard Service Call"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="rate_kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
</group>
<group>
<field name="price"/>
<field name="currency_id" invisible="1"/>
<field name="unit"/>
<field name="adds_per_km"/>
<field name="included_labour_min"/>
<field name="product_id"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_service_rate" model="ir.actions.act_window">
<field name="name">Service Rates</field>
<field name="res_model">fusion.service.rate</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Define your field-service rate card</p>
<p>Call-out fees, labour, per-km and delivery charges used by the service booking wizard.</p>
</field>
</record>
<menuitem id="menu_fusion_service_rate"
name="Service Rates"
parent="PARENT_MENU_XMLID"
action="action_fusion_service_rate"
sequence="90"/>
</odoo>
```
Register in `fusion_claims/__manifest__.py` `data` list, **after** `'views/res_config_settings_views.xml'` (or near the other views):
```python
'views/service_rate_views.xml',
```
- [ ] **Step 4: Verify load + menu (on the clone)**
Run the `-u fusion_claims --stop-after-init` command; expected: no error.
Then in `odoo shell -d westin-v19-ratetest`: `env.ref('fusion_claims.action_fusion_service_rate')` resolves; `env['fusion.service.rate'].search_count([])` ≥ 14. `env.cr.rollback()`.
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/security/ir.model.access.csv fusion_claims/views/service_rate_views.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL"
```
---
## Task 5: Version bump + final verify
**Files:** Modify `fusion_claims/__manifest__.py`
- [ ] **Step 1: Bump version**
In `fusion_claims/__manifest__.py`, bump `'version'` (e.g. `19.0.9.2.0``19.0.9.3.0`).
- [ ] **Step 2: Full upgrade + test run (on the clone)**
Run the canonical test command (`--test-tags /fusion_claims.TestServiceRate`). Expected: all PASS, module upgraded, no warnings about the new data files.
- [ ] **Step 3: Manual smoke (browser, on the clone)**
Open *Service Rates* menu → confirm 14+ rows, prices editable inline, a new row can be added and saved. Toggle one `active` off and back.
- [ ] **Step 4: Commit**
```bash
git add fusion_claims/__manifest__.py
git commit -m "chore(fusion_claims): bump version for service-rate foundation"
```
---
## Self-Review (done while writing)
- **Spec coverage:** §6.1 model fields ✓ (Task 1), seed products ✓ (Task 2), seed rows incl. $185/$205 + per-km + labour + delivery ✓ (Task 3), Service Rates menu/views/ACL ✓ (Task 4), §3 values as seed ✓. Resolver API (`get_callout`/`get_rate`) ✓ (Task 1) — consumed by Plan 2.
- **Placeholders:** none — every step has full code. The one deliberate lookup is the menu parent (Task 4 Step 2), which is a real "find the xmlid" action, not a vague TODO.
- **Type/name consistency:** `get_callout(category, timing, in_shop)` and `get_rate(code)` signatures match the tests and the seed codes (`callout_standard_normal`, `per_km`, `labour_inshop` reusing `product_labor_hourly`). Rate `code`s match across data + tests.
- **Gap noted for Plan 2:** the `_product_variant` external-ID convention (Task 3 note) — Plan 2's SO builder uses `rate.product_id` directly, so it's unaffected.
---
## Execution Handoff
This is **Plan 1 of 2**. **Plan 2** (booking wizard: tz fix, constraint relax, pricing resolver consuming `get_callout`/`get_rate`, SO builder, `action_book_from_wizard`, OWL wizard + SCSS, entry point) will be written next and depends on this.
Before executing: move this work to a dedicated branch (e.g. `claude/technician-service-booking`) — it's currently alongside the unrelated fusion_schedule fixes.

View File

@@ -0,0 +1,101 @@
# NexaCloud → Odoo Centralized Billing — Cutover (build-out · dual-run · gated flip)
- **Date:** 2026-06-02
- **Status:** Design approved — pending written-spec review
- **Author:** Design session (Claude + Gurpreet)
- **Parent spec:** [`2026-05-27-nexa-billing-centralized-design.md`](2026-05-27-nexa-billing-centralized-design.md) (architecture; this doc is its **phase #2** — the NexaCloud pilot)
- **Repos:** `K:\Github\Odoo-Modules\fusion_centralize_billing` (engine) + `K:\Github\Nexa-Cloud` (the NexaCloud adapter)
- **Hosts:** `odoo-nexa` (VM 315, Odoo 19 Enterprise, live DB `nexamain`); NexaCloud (LXC 102, app `192.168.1.250`, DB `192.168.1.50`)
## 1. Goal
Make Odoo (`fusion_centralize_billing` on `odoo-nexa`) the system of record for **NexaCloud** billing: build the engine pieces NexaCloud needs, import NexaCloud's active deployments as Odoo subscriptions, run Odoo in **shadow** alongside NexaCloud's existing Stripe billing for ≥1 cycle, reconcile to the cent, and then **flip** NexaCloud onto Odoo behind an explicit go/no-go gate. NexaCloud is the pilot; NexaDesk and NexaMaps follow in later increments. This does not touch Lago.
## 2. Decisions locked (this session, 2026-06-02)
1. **Sequence: NexaCloud first** (per parent spec), then NexaDesk, then NexaMaps.
2. **Granularity: one Odoo subscription per NexaCloud deployment** (mirrors `nexacloud` `subscriptions.deployment_id`; the existing usage-push and `fusion.billing.reconciliation` code already key per deployment via `x_fc_nexacloud_subscription_id`).
3. **Approach A: build → import → dual-run → gated flip**, all in this increment; the flip executes only after ≥1 green reconciliation cycle **and** explicit operator go-ahead.
4. **Go-forward billing only.** The importer sets each subscription's `next_invoice_date` so Odoo bills only future periods. Past NexaCloud periods are **never re-issued** (this is the exact failure mode of the 2026-05-27 Lago incident — see `lago-doublecharge-incident-2026-06` memory).
## 3. Current state (recon, 2026-06-02)
Engine is **installed** on `nexamain` (`fusion_centralize_billing` v19.0.1.1.0; deps `sale_subscription`, `payment_stripe`, `account_accountant` installed). Runtime rows:
| Table | Rows | Read |
|---|---|---|
| `fusion_billing_service` | 1 | only `nexacloud`; **`webhook_url` empty** |
| `fusion_billing_account_link` | 7 | identities imported |
| `fusion_billing_metric` | 1 | (cpu_seconds) |
| `fusion_billing_charge` | **0** | no quota/overage pricing yet |
| `fusion_billing_usage` | **0** | nothing ingested |
| `fusion_billing_reconciliation` | **0** | dual-run never run |
| `fusion_billing_webhook` | **0** | control loop never fired |
| `sale_order` (`is_subscription`) | **0** | no subscriptions exist |
Engine code status: `webhook.py` delivery engine (HMAC + backoff + dead-letter) is **complete** (its "TODO §8" header comment is stale); `usage.py` (idempotent upsert + pre-invoice rating cron + aggregation) and `reconciliation.py` (NexaCloud dual-run) are **complete**. `controllers/api.py` implements `/health`, `POST /customers`, `POST /usage`, `GET /plans`, `POST /subscriptions` only — the rest of parent-spec §7 is unimplemented (needed by NexaDesk, **not** NexaCloud).
NexaCloud adapter is present but **INERT**: `config.py` `odoo_billing_enabled=False`, `odoo_billing_base_url`/`odoo_billing_api_key` empty; `usage_metering.py` pushes `cpu_seconds` only when enabled; `routers/odoo_billing.py` `/billing/webhooks/central` returns 404 when disabled; `services/odoo_billing_integration.py` is the (inert) receiver. Lago is paused (worker+clock stopped) and out of scope here.
## 4. Scope
### 4.1 Odoo side (`fusion_centralize_billing` + catalog data on `nexamain`)
1. **Charge catalog (the main gap — currently 0).**
- NexaCloud plans/products → `product.template` + `sale.subscription.plan` (monthly), each tagged `plan_code` and a `product.default_code` of `NC-PLAN-<slug>` (reconciliation already filters plan lines on `default_code LIKE 'NC-PLAN-%'`).
- `cpu_seconds` metric (exists) → one `fusion.billing.charge` per plan: `included_quota` = the plan's bundled CPU-seconds, `price_per_unit`/`unit_batch` for overage derived from `usage_metering.HOURLY_RATES` (`cpu_per_core=$0.0075/core-hr` → per-cpu-second rate). Memory/disk are part of the flat plan today (not metered) — keep them flat unless a plan meters them.
- Throttle-removal fee and the CPU/RAM/disk/daily-backup **add-ons** → one-off invoice products / optional recurring add-on products tagged `NC-ADDON-<slug>`.
- HST: reuse native `account.tax` (13% ON); confirm the tax code matches what NexaCloud invoices apply today.
2. **Run the importer** (`wizards/import_wizard.py`): read the `nexacloud` DB → ensure `res.partner` + `account.link` for each active customer (7 exist; backfill any missing), and create **one shadow `sale.order` (`is_subscription=True`) per active deployment**, setting `x_fc_nexacloud_subscription_id`, `x_fc_nexacloud_plan_id`, the `NC-PLAN-*` line, and **`next_invoice_date` = the deployment's next real billing date** (go-forward only). Subscriptions start in shadow (draft/not auto-charging).
3. **Inbound API — add only what NexaCloud needs.** `POST /customers`, `POST /subscriptions`, `POST /usage`, `GET /plans` already exist. Add **subscription cancel** (`DELETE /subscriptions/:id` → terminate the `sale.order`) for NexaCloud's deprovision path. All other parent-spec §7 endpoints stay deferred to the NexaDesk increment.
4. **Wire the control loop:** set the `nexacloud` `fusion.billing.service.webhook_url``https://api.vps.nexasystems.ca/api/v1/billing/webhooks/central`, and confirm `cron` schedules for `usage._cron_rate_open_periods` and `webhook._cron_dispatch` are enabled.
### 4.2 NexaCloud side (`Nexa-Cloud` repo)
4. **Configure + activate the adapter:** set `odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1`, `odoo_billing_api_key=<nexacloud service key>`. Keep `odoo_billing_enabled` staged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe.
5. **Identity + subscription sync:** on deployment create / cancel, call Odoo `POST /customers` and `POST /subscriptions` / cancel (usage push already exists in `usage_metering.py`). Send a stable `external_id` (NexaCloud user id) and `subscription_external_id` (deployment/subscription id) — namespaced, to avoid the cross-app `external_id` collision noted in `nexa-billing-architecture`.
6. **Reconciliation feed:** push NexaCloud's **actual** charged amount per (deployment, period) so `reconciliation._reconcile_rows` can diff Odoo-computed vs NexaCloud-actual. (Source: NexaCloud's own invoices/`usage_records`.)
7. **Activate the control-loop receiver:** `routers/odoo_billing.py` `/billing/webhooks/central``services/odoo_billing_integration.py` maps `invoice.payment_failed`→suspend (existing `network_isolation`/`throttle_checker`/`resource_manager`), `invoice.payment_succeeded`/`subscription.reactivated`→restore, `subscription.terminated`→deprovision. Verify HMAC against the `nexacloud` service `webhook_secret`.
### 4.3 Dual-run (shadow, ≥1 billing cycle)
NexaCloud keeps charging via its own Stripe. Odoo computes **draft, uncharged** invoices from imported subscriptions + pushed `cpu_seconds`. `fusion.billing.reconciliation` upserts one row per `(service, deployment, period)` with `odoo_amount` vs `external_amount` and a cent-level `delta`. Operators investigate every `delta` row until a full cycle is `match` within tolerance (default $0.01).
### 4.4 Gated flip (after ≥1 green cycle + explicit go)
1. NexaCloud **stops its own Stripe charging** (disable the charge path in `billing_service.py` / scheduler `billing_payment` + invoice generation) and treats Odoo as SoR.
2. Odoo subscriptions move from shadow → active; native subscription invoicing charges the **shared** Stripe account `acct_1ShlA9IkwUB1dVox` (saved cards carry over — no re-collection).
3. Webhooks drive suspend/restore/deprovision. Past NexaCloud invoices remain archived (PDF/opening balance) — **not** re-issued.
4. Rollback: re-enable NexaCloud local billing + set Odoo subs back to shadow (no data destroyed).
## 5. Out of scope (YAGNI for this increment)
- NexaDesk and NexaMaps adapters (later increments) and the inbound-API endpoints only they need (`/invoices` family, `/credit_notes`, `/catalog`, `/checkout_url`, `PUT /subscriptions` plan-change/upgrade).
- Lago changes or decommission (Lago stays paused; its remediation is tracked separately).
- Customer-portal redesign — use native Odoo portal as-is.
- Metering memory/disk/bandwidth (stay flat unless a NexaCloud plan already meters them).
## 6. Success criteria
- A NexaCloud deployment is created as an Odoo subscription `sale.order` (`is_subscription=True`) via `POST /subscriptions`, resolving one `res.partner` through `account.link`.
- `cpu_seconds` counters pushed to `/usage` aggregate (idempotent) into a **draft** invoice with quota → free, overage priced, HST applied — matching NexaCloud's own computed amount within $0.01.
- A simulated `invoice.payment_failed` webhook reaches `/billing/webhooks/central` (valid HMAC) and triggers a NexaCloud suspend; `invoice.payment_succeeded` restores.
- `fusion.billing.reconciliation` is `match` for **every** active deployment across ≥1 full cycle before any flip.
- Re-sending the same usage counter (same `idempotency_key`) does **not** double-bill (constraint + upsert verified by test).
- Post-flip: Odoo charges go-forward periods only; **zero** past-period re-issues.
## 7. Risks & open items
- **Re-billing regression (highest):** the importer MUST set `next_invoice_date` go-forward and must not finalize/charge historical periods. Add an explicit test asserting no invoice is generated for any period earlier than import time. (Direct mitigation of the 2026-05-27 Lago incident.)
- **Odoo 19 correctness:** read live reference files from the container (`docker exec odoo-nexa-app cat …`) for `sale.order` subscription flow, `account.move`, `payment_stripe` before coding internals — never from memory (per `K:\Github\CLAUDE.md`).
- **Idempotency:** `fusion.billing.usage` unique `(subscription, metric, idempotency_key)` already enforces it; the NexaCloud key is `nexacloud:cpu_seconds:<sub>:<period>` — keep it stable across retries.
- **external_id namespacing:** NexaCloud must send namespaced ids so it can never collide with NexaDesk/NexaMaps in the shared Odoo identity space.
- **Reconciliation source:** confirm where NexaCloud's "actual amount" comes from (its `invoices`/`usage_records`) and that it's net of the same HST basis Odoo uses.
- **Flip switch safety:** disabling NexaCloud's local Stripe must be a single, reversible config flag, and the `billing_payment` scheduler job must be guarded so it can't charge once Odoo is SoR.
- **Spec/branch target:** `Odoo-Modules` is on `feat/fusion-login-audit` with `-wt-portal`/`-wt-fm` worktrees; confirm the branch for engine changes; NexaCloud changes land on its own branch (note: pushing `Nexa-Cloud` `main` auto-deploys to prod).
## 8. Test plan
- Odoo unit tests (extend `fusion_centralize_billing/tests/`): catalog→charge mapping; usage aggregation + quota/overage; idempotent re-push; reconciliation match/delta; webhook HMAC sign/verify + backoff; **importer go-forward `next_invoice_date` assertion**.
- NexaCloud tests: adapter customer/subscription calls; `/billing/webhooks/central` HMAC verify + suspend/restore/deprovision dispatch; reconciliation-amount push.
- Dual-run acceptance: a full cycle of `match` reconciliation on real (or staged) deployments before the flip gate.

View File

@@ -0,0 +1,172 @@
# Technician Service Booking & Auto-Quote — Design Spec
**Date:** 2026-06-03
**Modules:** `fusion_tasks` (booking wizard, task, time/tz), `fusion_claims` (SO link, rate-card products, SO creation)
**Status:** Draft for review
**Mockup:** `docs/superpowers/mockups/technician-booking-wizard.html` (v2)
---
## 1. Problem & Goal
Operators booking a technician service today use the raw `fusion.technician.task` form in a modal. Three problems:
1. **Forced SO:** a hard constraint (`fusion_claims/models/technician_task.py:105 _check_order_link`) requires a Sale Order **or** Purchase Order for every task except `ltc_visit`. A repair for a brand-new client (no SO yet) is blocked.
2. **Time fields:** Start/End use a 24-hour `float_time` widget while every other view shows 12-hour AM/PM; and the local→UTC conversion is inconsistent (`_compute_datetimes` resolves *company-calendar-tz → user-tz → UTC*, but `_inverse_datetime_*` uses *user-tz → UTC* only — they disagree, and fall back to UTC when unset).
3. **No revenue capture at booking:** the booking creates a task but no priced order, even though every service call has a defined call-out fee.
**Goal:** a fast, polished **"Book a Service"** wizard that, from one screen, (a) captures the client — including brand-new clients inline, (b) books the technician task, (c) prices the call-out from the rate card, and (d) auto-creates a **draft repair Sale Order**. Every service call becomes a revenue-tracked order. Works in dark + light.
---
## 2. Scope
**In:** OWL booking wizard (complete design freedom); inline new-client create (name/phone/email/address); rate-card product catalog; service-type → call-out pricing; auto draft repair SO (call-out line + auto per-km); live on-screen estimate; 12-hour AM/PM time entry; timezone-conversion fix; relaxation of the SO constraint.
**Out (phase 2):** deposit/payment capture; multi-technician labour auto-doubling; SMS gateway; maintenance/PM plans; full quote builder (estimated labour & parts written onto the SO at booking — for now the SO carries call-out + per-km only, labour/parts added as actuals).
---
## 3. Pricing model (Westin rate card)
> These values only **seed** the editable `fusion.service.rate` table (§6.1). After install, admins
> change any price and add new rate types from the **Service Rates** menu — nothing here is hardcoded,
> and the wizard reflects edits live.
### 3.1 Call-out fee matrix (the guaranteed charge; includes 30 min labour where noted)
| Category | Normal | Rush (+km) | After-Hours (+km) |
|---|---|---|---|
| **Standard** | $95 | $120 | $140 |
| **Lift & Elevating** | $160 | **$185** ◆ | **$205** ◆ |
-**Suggested fills** (not on the printed card). Derived from the card's own surcharge deltas: Standard Rush = +$25, After-Hours = +$45 over base; same deltas applied to the Lift base ($160) → $185 / $205. *Owner to confirm.*
- **Rush & After-Hours** add **$0.70/km × 2-way** (round trip), computed from the booking's travel distance.
- **In-shop (any device):** no call-out fee; labour billed at $75/hr; no delivery.
### 3.2 Labour (hourly, pro-rated in 30-min increments; per technician)
- On-site (Standard): **$85/hr**
- In-shop: **$75/hr** (already exists as product `LABOR`, default_code `LABOR`)
- Lift & Elevating on-site: **$110/hr**
### 3.3 Travel
- Per-km surcharge: **$0.70/km × 2-way**
### 3.4 Delivery / Pickup
| Item | Price |
|---|---|
| Local (within Brampton) | $35 |
| Outside local area | $60 |
| Rush pickup/delivery | $60 + $0.70/km ×2-way |
| Lift-chair delivery & set-up | $120 |
| Hospital-bed delivery & set-up | $120 |
| Stairlift delivery & set-up | $300 |
| Stairlift removal | $300 |
### 3.5 Footnote rules (from the card)
- A Service Call is an appointment **outside** a Westin location, billed **once per request**, includes **30 min labour**; labour rates apply after.
- Parts are **not** charged when covered under manufacturer warranty (→ "Under warranty" flag on the wizard).
- Multiple technicians → labour applies **per technician** (phase-2 auto-double; for now informational).
---
## 4. UX — wizard layout
Single page (no multi-step), grouped cards, brand-gradient header, dark/light. Sections (see mockup v2):
- **Customer** — segmented `Existing customer | New client`. Existing = search by **phone / name / SO** → prefill. New = **name, phone, email, address (street/unit/buzz/city)** inline; contact find-or-created on save.
- **Service & Pricing** — *device being serviced* (→ auto-suggests category: scooter/chair/bed → Standard; stairlift/lift → Lift & Elevating), *issue/symptom*, *service call type* (category × timing), and the resulting **call-out fee** readout.
- **Schedule** — date, **12-hour AM/PM start picker**, duration → auto end ("Ends at 10:00 AM · local time"), technician + availability hint.
- **Location** — **in-shop toggle** (drives pricing: no call-out, $75 labour, hides address), job address.
- **Job details** — work description, parts to bring, **under-warranty** toggle, POD, send-confirmation, request-review.
- **Estimate** (prominent strip) — *call-out + est. labour + per-km = total*; "a draft repair SO is created."
- **Footer** — Cancel · **Book & Create SO**.
Behaviours: device→category auto-suggest (overridable); in-shop flips pricing & hides address + call-out; live estimate recomputes on every change; AM/PM picker stores local float hours.
---
## 5. Architecture
**Complete UI freedom without duplicating backend logic:**
- **OWL client action** `fusion_tasks.service_booking` — renders the layout; loads reference data (technicians, device types, rate products, customer search) via standalone `rpc()` (`@web/core/network/rpc`). Registered in `registry.category("actions")`. Opened from a "Book a Service" button/menu/dashboard tile (`ir.actions.client`).
- **One server method** `fusion.technician.task.action_book_from_wizard(payload)`:
1. Resolve customer — search `res.partner` by email then phone; create if new (name/phone/email/address). For "existing", use the chosen partner/SO's partner.
2. Compute **travel distance now** (Google Distance Matrix via the existing `_calculate_travel_time`/`_get_google_maps_api_key`) from the shop / previous task to the job — needed for the per-km line.
3. Create a **draft `sale.order`** tagged as a repair (see §6) with the **call-out product line** + an **auto per-km line** (qty = round(distance_km × 2), product = per-km $0.70) when the service type is Rush/After-Hours.
4. Create the `fusion.technician.task` linked to that SO (reuses existing model `create` + address-fill + travel-chain logic).
5. Return `{task_id, order_id}` so the client action can open the task or close.
- **SCSS** `fusion_tasks/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss`, branching on `$o-webclient-color-scheme` (per repo rule), registered in `web.assets_backend` **and** `web.assets_web_dark`. Three-layer contrast tokens (page → card → field), explicit hex.
All validation/workflow/pricing stays server-side; the OWL component is presentation + a single submit call.
---
## 6. Data model changes
### 6.1 New: editable rate table `fusion.service.rate` (the configurable pricing control)
A dedicated model so admins manage **all** pricing from a **Service Rates** menu — no code to change a price or add a service type.
**Fields:** `name`; `code` (unique, e.g. `callout_standard_normal`, `callout_lift_rush`, `labour_onsite`, `labour_lift`, `per_km`, `delivery_local`); `rate_kind` (callout / labour / travel / delivery / other); `category` (standard / lift / na); `timing` (normal / rush / afterhours / na); `in_shop` (bool); `product_id` (the `product.product` used on the SO line — for description, tax, income account); `price` (Monetary — the **editable source of truth**); `unit` (fixed / per_hour / per_km); `adds_per_km` (bool); `included_labour_min` (int, e.g. 30); `active`; `sequence`; `currency_id`.
- **Seed** (`data/service_rate_data.xml`, `noupdate=1`): one row per §3 rate, each linked to a seeded `product.product` (type `service`, `sale_ok`, correct UoM — hour/km/unit, HST). `noupdate=1` means a later `-u` never overwrites admin price edits.
- **Views/menu:** list + form under *Field Service → Configuration → Service Rates* (manager-only) — edit price, add/remove rows, toggle `active`.
- **Products still exist** (SO lines + accounting need a product), but the **rate row's `price` is the source of truth** — the SO line takes `price_unit` from the rate, not the product's `list_price`. One place to edit.
- The **wizard builds its service-type selector from the active `callout` rows**, so a new rate row appears in the wizard automatically.
### 6.2 `fusion_tasks` — `fusion.technician.task`
- Make `_compute_datetimes` and `_inverse_datetime_start/_end` use **one** tz resolver (`_get_local_tz()` everywhere) so compute and inverse agree; document that local float hours ↔ UTC datetime is the single source of truth.
- Time entry stays `time_start`/`time_end` floats (local); the **AM/PM presentation lives in the OWL wizard**; the existing `time_start_display` (12h) already covers list/kanban/calendar.
### 6.3 `fusion_claims` — `fusion.technician.task` + `sale.order`
- **Relax** `_check_order_link`: no longer raise when there is no SO/PO — the wizard now auto-creates the SO, and in-shop/walk-in tasks may legitimately have none. (Keep the helper that auto-fills address from an SO when one *is* linked.)
- Add `x_fc_service_call_type` (Selection: standard/lift × normal/rush/afterhours, + in_shop) on the task, set by the wizard, used to pick the call-out product and for reporting.
- Add a **pricing resolver** that reads `fusion.service.rate`: `_get_callout_rate(category, timing, in_shop)` and `_get_rate(code)` (per-km, labour, delivery) + `_build_service_so(partner, rate, distance_km, ...)` that creates the SO + lines using each rate's `product_id` with `price_unit` taken from the rate row.
- **Repair-SO identity:** boolean `x_fc_is_service_repair` on `sale.order` + an `crm.tag`/SO tag "Service Repair" so these orders are filterable; reuse the standard quotation flow.
---
## 7. Pricing engine
- Reads the **`fusion.service.rate`** table (§6.1) — never hardcoded.
- `_get_callout_rate(category, timing, in_shop)` → the matching active `callout` row (none if in-shop). Its `price` → the SO call-out line `price_unit`; its `product_id` → the line product.
- **Per-km:** when the call-out row's `adds_per_km` is set, add a line from the `per_km` rate row, qty = `round(distance_km × 2)`, `price_unit` = that row's price.
- **On-screen estimate (UI only, not written to SO):** `callout.price + max(0, duration included_labour_min/60) × labour_rate + per-km`, where `labour_rate` is read from the `labour_*` rate rows (in-shop / on-site / lift).
---
## 8. Timezone fix (folds in the audit finding)
Single resolver `_get_local_tz()` (company resource-calendar tz → user tz → UTC) used by **both** `_compute_datetimes` and the inverses, eliminating the compute/inverse mismatch and the silent UTC fallback. Booking writes local float hours; datetime_start/end (UTC) recompute consistently for the calendar/sync.
---
## 9. Open decisions (defaults chosen — confirm at review)
| # | Decision | Default |
|---|---|---|
| 1 | Lift Rush / After-Hours call-out | **$185 / $205** (parallel surcharge) |
| 2 | In-shop pricing | no call-out, labour @ $75/hr, no delivery |
| 3 | Repair-SO identity | boolean `x_fc_is_service_repair` + SO tag "Service Repair" |
| 4 | Estimate labour | on-screen guide only; SO = call-out + per-km; labour/parts as actuals |
| 5 | Per-km distance basis | Distance Matrix, shop/previous-task → job, ×2-way |
| 6 | Rate configurability | editable `fusion.service.rate` table + **Service Rates** menu; the card only seeds it, admin-owned thereafter |
---
## 10. Testing & rollout
- Enterprise-only stack (these modules need `fusion_claims`/`fusion_portal` deps) → **verify on a Westin clone**, not local Community.
- Seed products + taxes; smoke-test: new-client booking → contact + task + draft SO created with the right call-out (+ per-km on rush/after-hours); existing-customer booking; in-shop (no call-out); tz correctness on the task + calendar; dark + light bundles.
---
## 11. Build sequence (for the implementation plan)
1. **`fusion.service.rate` model** + seeded rows + products + taxes + *Service Rates* menu/views.
2. **TZ fix** + confirm AM/PM round-trips (time floats).
3. **Constraint relax** + `x_fc_service_call_type` + pricing resolver + `_build_service_so` + `action_book_from_wizard` (server).
4. **OWL wizard** client action + SCSS (dark/light).
5. **Entry point** (button/menu/tile) + `ir.actions.client`.
6. **Clone-verify** end-to-end.

View File

@@ -247,3 +247,24 @@ class FusionBillingService(models.Model):
sub.action_confirm()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}
def _api_cancel_subscription(self, external_ref):
"""Cancel (close) the subscription identified by ``external_ref``.
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
exist, be a subscription, and belong to a customer THIS service is linked
to. Idempotent — closing an already-churned subscription returns ok.
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
"""
self.ensure_one()
if external_ref in (None, ''):
return {'status': 'error', 'error': 'subscription id required'}
sub = self._fc_resolve_subscription(external_ref)
linked_partners = self.account_link_ids.mapped('partner_id')
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}
if sub.subscription_state != '6_churn':
sub.set_close()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}

View File

@@ -6,3 +6,4 @@ from . import test_webhook
from . import test_importer
from . import test_reconciliation
from . import test_invoice_ledger
from . import test_subscription_cancel

View File

@@ -18,11 +18,26 @@ def _inv_fixture():
}]
def _fc_ensure_ca_billing_env(env):
"""Prod (`nexamain`) is a fully-configured Canadian company; a bare test DB is not.
Give it the two things the ledger needs: an active CAD currency and a 13% sale tax
matching invoice.ledger.wizard._fc_tax_for (type_tax_use=sale, percent, amount=13)."""
cad = env.ref('base.CAD')
if not cad.active:
cad.sudo().write({'active': True})
Tax = env['account.tax'].sudo()
if not Tax.search([('type_tax_use', '=', 'sale'),
('amount_type', '=', 'percent'), ('amount', '=', 13.0)], limit=1):
Tax.create({'name': 'HST 13%', 'type_tax_use': 'sale',
'amount_type': 'percent', 'amount': 13.0})
@tagged('post_install', '-at_install')
class TestLedgerFamily(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
def test_family_classification(self):
@@ -47,6 +62,7 @@ class TestLedgerTax(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
@@ -68,6 +84,7 @@ class TestLedgerIngest(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
self.Move = self.env['account.move']
@@ -174,6 +191,7 @@ class TestLedgerVerifiedSync(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
self.Move = self.env['account.move']
ICP = self.env['ir.config_parameter'].sudo()

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSubscriptionCancel(TransactionCase):
def _service(self, code, name):
Svc = self.env['fusion.billing.service'].sudo()
return Svc.search([('code', '=', code)], limit=1) or Svc.create(
{'name': name, 'code': code})
def setUp(self):
super().setUp()
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.product = self.env['product.product'].sudo().create(
{'name': 'NexaCloud Plan', 'type': 'service',
'recurring_invoice': True, 'list_price': 49.0})
self.svc_a = self._service('nexacloud', 'NexaCloud')
self.svc_b = self._service('other_app', 'Other App')
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
res = self.svc_a._api_create_subscription({
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
self.sub = self.env['sale.order'].browse(res['subscription_id'])
def test_cancel_closes_subscription(self):
self.assertEqual(self.sub.subscription_state, '3_progress')
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_is_idempotent(self):
self.svc_a._api_cancel_subscription(str(self.sub.id))
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_unknown_subscription_rejected(self):
res = self.svc_a._api_cancel_subscription('999999999')
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
def test_cancel_cross_service_rejected(self):
# svc_b is not linked to the customer that owns self.sub
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertEqual(self.sub.subscription_state, '3_progress')
def test_cancel_missing_id_rejected(self):
res = self.svc_a._api_cancel_subscription('')
self.assertEqual(res['status'], 'error')

View File

@@ -9,7 +9,8 @@ class TestRatingCron(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
Metric = self.env['fusion.billing.metric'].sudo()
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
@@ -67,7 +68,8 @@ class TestUsageIngestion(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
Metric = self.env['fusion.billing.metric'].sudo()
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})

View File

@@ -13,11 +13,17 @@ class TestWebhookEngine(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create({
Service = self.env['fusion.billing.service'].sudo()
vals = {
'name': 'NexaCloud', 'code': 'nexacloud',
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
'webhook_secret': 'whsec_test',
})
}
self.service = Service.search([('code', '=', 'nexacloud')], limit=1)
if self.service:
self.service.write(vals)
else:
self.service = Service.create(vals)
self.Webhook = self.env['fusion.billing.webhook'].sudo()
def test_enqueue_signs_payload(self):

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.4.1.0',
'version': '19.0.4.2.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """

View File

@@ -287,6 +287,11 @@ class FusionClockAPI(http.Controller):
attendance.sudo().write(write_vals)
# A successful clock-in resolves any pending missed-clock-out flag,
# so the employee is never nagged once they are back on the clock.
if employee.x_fclk_pending_reason:
employee.sudo().write({'x_fclk_pending_reason': False})
# Log clock-in
self._log_activity(
employee, 'clock_in',
@@ -542,7 +547,10 @@ class FusionClockAPI(http.Controller):
'is_checked_in': is_checked_in,
'employee_name': employee.name,
'enable_clock': employee.x_fclk_enable_clock,
'pending_reason': employee.x_fclk_pending_reason,
# Only nag when there is genuinely something to explain: a flag set,
# the employee NOT currently on the clock, and not attendance-exempt.
'pending_reason': (employee.x_fclk_pending_reason and not is_checked_in
and not employee._fclk_is_attendance_exempt()),
'ontime_streak': employee.x_fclk_ontime_streak,
}
local_today = get_local_today(request.env, employee)
@@ -728,7 +736,8 @@ class FusionClockAPI(http.Controller):
'is_checked_in': is_checked_in,
'check_in': check_in,
'location_name': location_name,
'pending_reason': employee.x_fclk_pending_reason,
'pending_reason': (employee.x_fclk_pending_reason and not is_checked_in
and not employee._fclk_is_attendance_exempt()),
'today_hours': today_hours,
'week_hours': week_hours,
'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2),

View File

@@ -137,6 +137,9 @@ class FusionClockKiosk(http.Controller):
'x_fclk_clock_source': 'kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
# Back on the clock -> clear any stale missed-clock-out flag.
if employee.x_fclk_pending_reason:
employee.sudo().write({'x_fclk_pending_reason': False})
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0, source='kiosk')

View File

@@ -345,6 +345,9 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_clock_source': 'nfc_kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
# Back on the clock -> clear any stale missed-clock-out flag.
if employee.x_fclk_pending_reason:
employee.sudo().write({'x_fclk_pending_reason': False})
api._log_activity(
employee, 'clock_in',
f"NFC kiosk clock-in at {location.name}",

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""One-time reset of stale missed-clock-out flags on upgrade to 19.0.4.1.0.
Background: x_fclk_pending_reason was set by the absence + auto-clock-out crons
but only cleared by the systray reason dialog -- never by the kiosk / NFC clock
paths that staff actually use. During the kiosk rollout the absence cron flagged
essentially the whole company (hundreds of "absent" logs), and those flags then
nagged everyone forever, even while currently clocked in.
This release clears the flag on every clock-in (all paths), stops absences from
setting it at all, and exempts owners. The flags already on record are stale
artifacts of the rollout, so wipe them once here; correct ones re-appear only
for a genuine forgotten clock-out from now on.
"""
def migrate(cr, version):
if not version:
return
cr.execute(
"UPDATE hr_employee SET x_fclk_pending_reason = false "
"WHERE x_fclk_pending_reason = true"
)

View File

@@ -345,6 +345,9 @@ class HrAttendance(models.Model):
continue
employee = att.employee_id
# Owners / attendance-exempt employees are never auto-clocked-out or nagged.
if employee._fclk_is_attendance_exempt():
continue
clock_out_time = effective_deadline
try:
with self.env.cr.savepoint():
@@ -456,6 +459,9 @@ class HrAttendance(models.Model):
for emp in employees:
try:
with self.env.cr.savepoint():
# Owners / attendance-exempt employees are never flagged absent.
if emp._fclk_is_attendance_exempt():
continue
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
# Only days the employee was actually scheduled to work
@@ -498,7 +504,11 @@ class HrAttendance(models.Model):
'source': 'system',
})
emp.sudo().write({'x_fclk_pending_reason': True})
# NOTE: an absence does NOT set x_fclk_pending_reason. That flag
# drives the "explain your missed clock-OUT (departure time)"
# dialog, which is meaningless for a day with no attendance and
# caused a persistent false nag. The absence is logged + the
# office is notified on excess; that is the absence remedy.
month_start = yesterday.replace(day=1)
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
@@ -546,6 +556,9 @@ class HrAttendance(models.Model):
for emp in employees:
try:
with self.env.cr.savepoint():
# Owners / attendance-exempt employees are never reminded.
if emp._fclk_is_attendance_exempt():
continue
today = get_local_today(self.env, emp)
if not emp._get_fclk_day_plan(today).get('scheduled'):
continue
@@ -610,6 +623,9 @@ class HrAttendance(models.Model):
company_name = company.name or ''
for emp in employees:
# Owners / attendance-exempt employees get no weekly summary.
if emp._fclk_is_attendance_exempt():
continue
if not emp.work_email:
continue

View File

@@ -40,6 +40,18 @@ class HrEmployee(models.Model):
help="If set, employee must explain a missed clock-out before clocking in again.",
)
# Attendance exemption (owners / anyone who works but is not "on the clock").
# Exempt employees are skipped by absence detection, auto-clock-out and
# reminders, and never see the missed-clock-out reason dialog.
x_fclk_exempt_from_attendance = fields.Boolean(
string='Exempt from Attendance Tracking',
default=False,
help="If set, this employee is never flagged absent, auto-clocked-out, "
"reminded, or asked to explain a missed clock-out. Use for owners "
"and others who work but are not on the clock. The Fusion Clock "
"'Owner' role grants this automatically.",
)
# Kiosk PIN
x_fclk_kiosk_pin = fields.Char(
string='Kiosk PIN',
@@ -122,6 +134,19 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _fclk_is_attendance_exempt(self):
"""True when this employee is exempt from attendance automation.
Exempt = the per-employee checkbox is set, OR the linked user holds the
Fusion Clock 'Owner' role. Exempt employees are never flagged absent,
auto-clocked-out, reminded, or shown the missed-clock-out reason dialog.
"""
self.ensure_one()
if self.x_fclk_exempt_from_attendance:
return True
user = self.user_id
return bool(user) and user.has_group('fusion_clock.group_fusion_clock_owner')
def _get_fclk_schedule_for_date(self, date):
"""Return this employee's dated Fusion Clock schedule for a local date."""
self.ensure_one()

View File

@@ -49,6 +49,18 @@
<field name="comment">Can manage locations, view all attendance, generate reports</field>
</record>
<!-- Owner: top of the role ladder. Carries ALL Manager permissions but is
exempt from attendance automation (no absence flags, no auto-clock-out
nag, no reminders, no missed-clock-out dialog). For owners/principals
who work but are not "on the clock". Implies Manager, so it renders as
the highest role in the single Fusion Clock access dropdown. -->
<record id="group_fusion_clock_owner" model="res.groups">
<field name="name">Owner</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_manager'))]"/>
<field name="comment">Full Clock management; exempt from attendance tracking, reminders and missed-clock alerts.</field>
</record>
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.

View File

@@ -71,7 +71,10 @@ export class FusionClockFAB extends Component {
this.state.todayHours = (result.today_hours || 0).toFixed(1);
this.state.weekHours = (result.week_hours || 0).toFixed(1);
if (result.pending_reason) {
// Never raise the missed-clock-out dialog while the employee is
// currently on the clock (the server already guards this, but keep
// the UI honest too).
if (result.pending_reason && !result.is_checked_in) {
this.state.showReasonDialog = true;
}

View File

@@ -10,3 +10,4 @@ from . import test_pay_period
from . import test_settings
from . import test_clock_kiosk
from . import test_break_rules
from . import test_pending_reason_exempt

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Regression tests for the missed-clock-out ("pending reason") nag and the
new owner/attendance-exemption.
Root cause these tests pin down:
* The `x_fclk_pending_reason` flag was set by the absence + auto-clock-out
crons but ONLY cleared by the systray reason dialog. The kiosk / NFC clock
paths (how Entech actually clocks in) never cleared it, so a stale flag
nagged employees forever -- even while currently clocked in.
* Owners work but are not "on the clock"; they must be exempt from absence
flagging, auto-clock-out nags and the reason dialog.
"""
import json
from datetime import date, timedelta
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import HttpCase, TransactionCase
try:
from freezegun import freeze_time
except ImportError: # freezegun may be absent on the runtime image
freeze_time = None
MON = date(2026, 6, 1) # Monday
TUE = date(2026, 6, 2) # Tuesday
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestAttendanceExemptHelper(TransactionCase):
"""`hr.employee._fclk_is_attendance_exempt()` truth table."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.owner_group = cls.env.ref('fusion_clock.group_fusion_clock_owner')
def test_plain_employee_not_exempt(self):
emp = self.Employee.create({'name': 'Plain', 'x_fclk_enable_clock': True})
self.assertFalse(emp._fclk_is_attendance_exempt())
def test_checkbox_makes_exempt(self):
emp = self.Employee.create({
'name': 'Flagged', 'x_fclk_enable_clock': True,
'x_fclk_exempt_from_attendance': True,
})
self.assertTrue(emp._fclk_is_attendance_exempt())
def test_owner_group_makes_exempt(self):
user = self.env['res.users'].create({
'name': 'Olivia Owner', 'login': 'olivia-owner-test',
'group_ids': [(4, self.owner_group.id)],
})
emp = self.Employee.create({
'name': 'Olivia Owner', 'x_fclk_enable_clock': True, 'user_id': user.id,
})
self.assertTrue(emp._fclk_is_attendance_exempt())
def test_owner_group_implies_manager(self):
"""The Owner role must carry full Manager permissions."""
user = self.env['res.users'].create({
'name': 'Manager-by-owner', 'login': 'owner-implies-mgr',
'group_ids': [(4, self.owner_group.id)],
})
self.assertTrue(user.has_group('fusion_clock.group_fusion_clock_manager'))
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestCronsRespectExemptAndPending(TransactionCase):
"""Absence + auto-clock-out crons: no more pending nag, owners skipped."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.Schedule = cls.env['fusion.clock.schedule']
cls.Attendance = cls.env['hr.attendance']
cls.Log = cls.env['fusion.clock.activity.log']
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_auto_clockout', 'True')
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
def _post(self, emp, day):
return self.Schedule.create({
'employee_id': emp.id, 'schedule_date': day, 'state': 'posted',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
})
def test_absence_does_not_set_pending_reason(self):
if freeze_time is None:
self.skipTest("freezegun not available")
emp = self.Employee.create({'name': 'NoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
self._post(emp, MON)
with freeze_time("2026-06-02 09:00:00"): # yesterday = scheduled Monday
self.Attendance._cron_fusion_check_absences()
# Absence is still logged ...
self.assertEqual(self.Log.search_count([
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 1)
# ... but it must NOT raise the missed-clock-out reason nag.
self.assertFalse(emp.x_fclk_pending_reason)
def test_absence_skips_exempt_employee(self):
if freeze_time is None:
self.skipTest("freezegun not available")
emp = self.Employee.create({
'name': 'OwnerNoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'x_fclk_exempt_from_attendance': True,
})
self._post(emp, MON)
with freeze_time("2026-06-02 09:00:00"):
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 0)
self.assertFalse(emp.x_fclk_pending_reason)
def test_auto_clockout_skips_exempt_employee(self):
emp = self.Employee.create({
'name': 'OwnerStale', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'x_fclk_exempt_from_attendance': True,
})
now = fields.Datetime.now()
stale = self.Attendance.create({
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertFalse(stale.check_out, "Exempt employee must not be auto-clocked-out.")
self.assertFalse(emp.x_fclk_pending_reason)
def test_auto_clockout_still_flags_normal_employee(self):
emp = self.Employee.create({'name': 'Forgetful', 'x_fclk_enable_clock': True, 'tz': 'UTC'})
now = fields.Datetime.now()
stale = self.Attendance.create({
'employee_id': emp.id, 'check_in': now - timedelta(hours=20),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")
self.assertTrue(emp.x_fclk_pending_reason, "Forgotten clock-out still asks for a reason.")
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestKioskClearsPendingReason(HttpCase):
"""Clocking in via either kiosk clears a stale pending-reason flag."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'Clear Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'Clear Op', 'login': 'clear-op', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.pin_emp = cls.env['hr.employee'].create({
'name': 'Pat Pending', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
'x_fclk_pending_reason': True,
})
cls.nfc_emp = cls.env['hr.employee'].create({
'name': 'Nina Pending', 'x_fclk_enable_clock': True,
'x_fclk_nfc_card_uid': '04:A2:B5:62:CC:01', 'x_fclk_pending_reason': True,
})
def setUp(self):
super().setUp()
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_mod
nfc_mod._recent_taps.clear()
def _post(self, route, params):
self.authenticate('clear-op', 'kioskpass123')
resp = self.url_open(route, data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': params,
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_pin_kiosk_clock_in_clears_pending(self):
res = self._post('/fusion_clock/kiosk/clock', {'employee_id': self.pin_emp.id})
self.assertEqual(res.get('action'), 'clock_in')
self.pin_emp.invalidate_recordset()
self.assertFalse(self.pin_emp.x_fclk_pending_reason)
def test_nfc_tap_clock_in_clears_pending(self):
res = self._post('/fusion_clock/kiosk/nfc/tap', {'card_uid': '04:A2:B5:62:CC:01'})
self.assertEqual(res.get('action'), 'clock_in')
self.nfc_emp.invalidate_recordset()
self.assertFalse(self.nfc_emp.x_fclk_pending_reason)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestGetStatusPendingReason(HttpCase):
"""get_status must never raise the dialog for a clocked-in or exempt user."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = cls.env['res.users'].create({
'name': 'Status User', 'login': 'status-user', 'password': 'statuspass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_user').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Status User', 'x_fclk_enable_clock': True, 'tz': 'UTC',
'user_id': cls.user.id, 'x_fclk_pending_reason': True,
})
def _status(self):
self.authenticate('status-user', 'statuspass123')
resp = self.url_open('/fusion_clock/get_status', data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': {},
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_pending_hidden_while_checked_in(self):
self.env['hr.attendance'].create({
'employee_id': self.emp.id, 'check_in': fields.Datetime.now() - timedelta(hours=1),
})
self.emp.invalidate_recordset()
res = self._status()
self.assertTrue(res.get('is_checked_in'))
self.assertFalse(res.get('pending_reason'),
"A currently clocked-in employee must never be nagged.")
def test_pending_hidden_for_exempt(self):
self.emp.write({'x_fclk_exempt_from_attendance': True})
res = self._status()
self.assertFalse(res.get('is_checked_in'))
self.assertFalse(res.get('pending_reason'),
"An exempt (owner) employee must never be nagged.")
def test_pending_shown_for_normal_not_checked_in(self):
"""Sanity: the dialog still works for a genuine forgotten clock-out."""
res = self._status()
self.assertFalse(res.get('is_checked_in'))
self.assertTrue(res.get('pending_reason'))

View File

@@ -15,6 +15,7 @@
<group>
<group string="Configuration">
<field name="x_fclk_enable_clock"/>
<field name="x_fclk_exempt_from_attendance"/>
<field name="x_fclk_shift_id"/>
<field name="x_fclk_default_location_id"/>
<field name="x_fclk_break_minutes"/>

View File

@@ -346,15 +346,30 @@ class ResUsers(models.Model):
string='Login Audit Count',
compute='_compute_x_fc_login_audit_count',
)
# NON-STORED on purpose — do NOT re-add store=True.
#
# These were store=True computed-from-the-audit-One2many. That meant every
# successful-login audit row (written through an INDEPENDENT
# registry.cursor(), see _fc_record_login_event) forced a recompute that
# flushed a write-back onto THIS res_users row. During portal-invitation
# acceptance the request has already locked that row (auth_signup just set
# the password in the same transaction), so the audit cursor's write-back
# blocked on the request's own row lock while the request's Python blocked
# waiting for the audit cursor — a self-deadlock Postgres cannot detect
# (the holder shows 'idle in transaction', not lock-waiting). Workers
# wedged for up to limit_time_real (20 min) and odoo-westin went
# unresponsive every time an invite was accepted (issue 2026-06-03).
#
# Keeping them non-stored means creating an audit row never touches
# res_users. They compute on read (display-only on the user form). The
# regression guard is tests.test_last_login_fields_not_stored.
x_fc_last_successful_login = fields.Datetime(
string='Last Successful Login',
compute='_compute_x_fc_last_successful_login',
store=True,
)
x_fc_last_login_ip = fields.Char(
string='Last Login IP', size=45,
compute='_compute_x_fc_last_successful_login',
store=True,
)
@api.depends('x_fc_login_audit_ids')

View File

@@ -303,6 +303,54 @@ class TestFusionLoginAuditModel(TransactionCase):
self.assertGreaterEqual(user.x_fc_login_audit_count, 1)
self.assertEqual(user.x_fc_last_login_ip, '198.51.100.42')
def test_last_login_fields_not_stored(self):
"""Regression guard for the 2026-06-03 invitation-acceptance hang.
x_fc_last_successful_login / x_fc_last_login_ip MUST stay non-stored.
When they were store=True (computed from the audit One2many), creating
the success audit row through the independent registry cursor forced a
write-back onto the very res_users row the request had already locked
(auth_signup had just set the password) -> a self-deadlock Postgres
cannot see (the holder shows 'idle in transaction'). Workers wedged for
up to limit_time_real and odoo-westin became unresponsive whenever an
invitation was accepted. Non-stored means audit-row creation never
touches res_users, so the deadlock cannot form.
"""
fields_ = self.env['res.users']._fields
self.assertFalse(
fields_['x_fc_last_successful_login'].store,
"x_fc_last_successful_login must be non-stored (see docstring)")
self.assertFalse(
fields_['x_fc_last_login_ip'].store,
"x_fc_last_login_ip must be non-stored (see docstring)")
def test_audit_row_create_does_not_write_res_users(self):
"""Creating a login-audit row must not write the linked res_users row.
This is the behavioural half of the deadlock guard: with the fields
non-stored, inserting an audit row for a user leaves that user's
write_date untouched (no recompute -> no res_users UPDATE -> nothing
to contend with the request's own row lock).
"""
user = self.env['res.users'].sudo().create({
'name': 'NoWriteback Tester',
'login': 'nowriteback-tester@example.com',
'password': 'nowriteback-tester-pw-1',
})
user.flush_recordset()
before = user.write_date
self.env['fusion.login.audit'].sudo().create({
'user_id': user.id,
'attempted_login': user.login,
'result': 'success',
'database': self.env.cr.dbname,
'ip_address': '198.51.100.7',
})
user.invalidate_recordset()
self.assertEqual(
user.write_date, before,
"Audit-row create must not write back to res_users")
def test_action_view_login_audit_returns_window_action(self):
"""The smart-button action returns an act_window scoped to this user."""
user = self.env['res.users'].sudo().create({

View File

@@ -630,8 +630,27 @@ De-Racking → Final inspection → Shipping`
Columns are first-class — they always render in this exact order, never
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
(stored) from `work_centre.area_kind` with a fallback to a step-kind
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`):
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the
`fp.step.kind` taxonomy is authoritative; the legacy `_STEP_KIND_TO_AREA`
dict is gone) → else catch-all `'plating'`.
**Gating/"Ready for X" marker steps fall FORWARD (fixed 2026-06-02).** The
`fp.step.kind` named *Gating* has `code='gating'` **and `area_kind='receiving'`**.
A gating step is a non-physical "ready for the next stage" marker, so
mapping it to Receiving made a *mid-recipe* gate snap the job's card back
to the first column (Racking → "Ready for processing" jumped to Receiving,
so the job looked like it vanished). `_compute_area_kind` therefore detects
a gating step via the **stable `kind_id.code == 'gating'`** (never the
display name) and resolves its column to the **next non-gating step's** raw
area (so "Ready for processing" before plating shows in the **Plating**
column); if nothing real follows, it falls back to the last real stage.
Helpers: `_fp_is_gating_step`, `_fp_raw_area_kind` (own work_centre/kind
only — no look-ahead, avoids recursion), `_fp_resolve_area_kind`. **NB:**
`area_kind` is a STORED compute, so after changing this logic you must
force-recompute existing rows (`env['fp.job.step'].search([])._compute_area_kind()`
+ `flush_recordset(['area_kind'])` + commit) — a `-u`/restart alone leaves
old values stale.
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
@@ -1847,20 +1866,42 @@ A 50-part job can have parts at several stages at once (10 Masking, 20 Plating,
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves.
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range).
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
**Verification:** the plating modules can't be installed on the local Community dev DB (missing enterprise deps — same reason `fusion_plating` shows `installed=0` in `modsdev`/`fusion-dev`). Static checks done: pyflakes (Python), lxml parse (XML), `node --check` as `.mjs` (JS — `node --check` on a `.js` errors with "Cannot use import statement outside a module"; copy to `/tmp/x.mjs` first). Dynamic tests + browser check require an installed env (entech / odoo-trial).
### Rollout fixes + open items (live operator testing, 2026-06-02)
Bugs that only real tablet testing surfaced (all fixed, deployed to entech, on main):
- **Phantom future-stage cards** — a job showed in every not-yet-started `ready` stage. Presence keys off parked qty / `in_progress`, never `ready` (gotcha 1).
- **Scan buttons** — camera button rendered two icons; "Scan Code" vs "Camera" was confusing. `QrScanner` keeps its single icon; now **"Scan QR"** (camera) + **"Enter Code"** (wedge/manual). Don't pass an emoji in the `QrScanner` label — it doubles the icon.
- **Dark-mode invisible text** — `var(--bs-body-color)` / `var(--bs-secondary-color)` are UNDEFINED in Odoo's backend CSS → always fall back to the dark hex. Use inherit / translucent `rgba()` (see the Dark-mode SCSS section).
- **Partial advance blocked by the from-step's own predecessor** — `_blockers_for_move` now blocks only steps STRICTLY BETWEEN from/to (gotcha 5).
- **First/seeded stage never auto-finished on drain** — `_fp_try_autofinish_on_drain` guards on a real OUTGOING move, not incoming.
- **Gating "Ready for X" steps zig-zagged the card back to Receiving** — gating steps fall FORWARD to the next real stage's column (see the Plant-View `area_kind` note).
Open / deferred (next session):
- **Discoverability (not built):** show a "N here" qty badge on step rows + the count on the Send button; add a "✓ all sent — record inputs to finish" hint when a step is drained-to-0 but still has a pending required input (answers operators' "why is it still active?").
- **Scrap / Rework as standalone intent buttons** — currently under the Move dialog's "More options"; only Hold has its own button.
- **Automated tests NOT written** — modules need enterprise deps (can't install on local Community); validated via pyflakes/lxml + live odoo-shell verification on entech. A `bt_s*`-style battle test is the recommended next step.
- **Plant-card status chips** read fine but bright in dark mode (deferred).
---
## Dark-mode SCSS gotchas — shop-floor dialogs/components (fixed 2026-06-02)
Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules:
1. **`var(--text-secondary, #333)` is a MADE-UP variable — it does not exist in Odoo, so it ALWAYS falls back to the hardcoded dark hex → invisible on dark backgrounds.** It was used 33× across `job_workspace.scss` + 5 component stylesheets. The real, dark-aware secondary-text variable is **`var(--bs-secondary-color)`** (CLAUDE.md rule 9 lists it). Never use `--text-secondary` / `--text-primary` / `--card-bg` etc. — those aren't Odoo vars.
1. **Odoo's compiled backend CSS does NOT define the Bootstrap colour custom-properties — `var(--bs-body-color)`, `var(--bs-secondary-color)`, `var(--bs-tertiary-bg)`, `var(--bs-body-bg)` are REFERENCED but never DEFINED (verified 2026-06-02: 0 definitions for `--bs-body-color`/`--bs-secondary-color` in the live `web.assets_backend` text).** So **any `color: var(--bs-body-color, #hex)` resolves to the `#hex` fallback in BOTH light and dark mode** — a dark hex → invisible on a dark surface. (`var(--text-secondary, …)` is even worse — that var name is entirely made-up.) Odoo themes the backend via **runtime `[data-bs-theme="dark"]`** (Bootstrap 5.3) + SCSS literals, NOT via those CSS vars, and NOT via `prefers-color-scheme`. Do NOT colour custom text with `var(--bs-*)`. **Correct, verified options:**
- **Inherit** — omit `color:` entirely so the element takes the dialog/page theme colour. Proven: the finish-block dialog's title + `.o_fp_finish_block_list` items have no colour and ARE readable in both modes; the `.o_fp_finish_block_msg` line was the ONLY broken one because it set `color: var(--bs-body-color,…)`. Removing that one line fixed it. This is the simplest fix for dialog/modal text.
- **Translucent `rgba()` for tinted boxes** — e.g. `background: rgba(245,158,11,0.16)` (warning) / `rgba(128,128,128,0.12)` (neutral). Works over whatever the live theme background is. (`color-mix(…, var(--bs-body-bg))` does NOT work — `--bs-body-bg` is undefined, so the whole `color-mix` is invalid and dropped.)
- **Explicit `[data-bs-theme="dark"] .my-class { color: … }`** override with literal hex when you genuinely need a different value per theme.
- **Compile-time `$o-webclient-color-scheme == dark`** literals only work if the **dark bundle is actually served**; on entech the active mechanism is runtime `[data-bs-theme]`, so prefer inherit / rgba / `[data-bs-theme=dark]` selectors over the two-bundle approach for backend dialogs.
NOTE: ~33 muted-text usages across `job_workspace.scss` + 5 component stylesheets still use `var(--bs-secondary-color, #hex)` (undefined → dark hex). They're muted/secondary so less glaring, but technically wrong in dark mode — sweep them to one of the patterns above when touched.
2. **Odoo's bootstrap does NOT define the Bootstrap 5.3 `--bs-{color}-bg-subtle` / `--bs-{color}-text-emphasis` family.** Verified by grepping `web/static/lib/bootstrap/scss/_root.scss`: `--bs-tertiary-bg` and `--bs-secondary-color` exist; `--bs-warning-bg-subtle`, `--bs-danger-bg-subtle`, `--bs-warning-text-emphasis` are MISSING. So `var(--bs-warning-bg-subtle, #fef3c7)` just yields the bright hex fallback — useless for dark mode. **For tinted status banners (warning/danger/info), use `color-mix` over the live theme bg instead:** `background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg)); color: var(--bs-body-color);` — pale in light mode, dark-tinted in dark mode, readable in both, graceful-degrades to no-bg on ancient browsers. (`color-mix` works in `background-color` per the rule-8 note; keep it out of shorthands.) Solid accent elements (selected pills, priority dots) with `color: white` are fine as-is in both modes.
3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set.

View File

@@ -0,0 +1,626 @@
# Multi-Rack Splitting at Racking — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an operator split a single work order's parts across multiple physical racks at the Racking step (default 1 rack with all parts; "+ Add Rack" divides equally; manual qty override), and move each rack independently through Plating → Baking → De-Racking, reusing the existing move log.
**Architecture:** A new first-class `fp.rack.load` record (+ `fp.rack.load.line` per work order) represents "parts on one rack." It carries its own workflow position and moves via the existing `fp.job.step.move` chain-of-custody log (one move row per line). Phase 1 is single-WO (one line per load); grouping is Phase 2. The UI is a Racking panel on the Job Workspace (mirrors the existing Receiving card).
**Tech Stack:** Odoo 19 (Python models + TransactionCase tests), OWL 2 (JS/XML/SCSS), JSONRPC controllers. Spec: `docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md`.
**Test command (local dev, Community):**
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_plating \
-u fusion_plating --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
---
### Task 0: Confirm integration field names (no code; grep only)
The plan references fields on existing models. Confirm exact names before writing code so later tasks reference real symbols.
- [ ] **Step 1: Confirm the job's recipe field + step/move/rack fields**
```bash
cd /Users/gurpreet/Github/Odoo-Modules/fusion_plating
grep -nE "recipe.*fields\.|_name = ['\"]fp\.job['\"]" fusion_plating_jobs/models/fp_job.py | head
grep -nE "area_kind|qty_at_step|qty_at_step_start|qty_at_step_finish|rack_id|requires_rack_assignment" fusion_plating/models/fp_job_step.py | head
grep -nE "qty_moved|transfer_type|to_step_id|from_step_id|rack_id|move_datetime|moved_by_user_id" fusion_plating/models/fp_job_step_move.py | head
grep -nE "capacity|capacity_count|racking_state" fusion_plating/models/fp_rack.py | head
grep -nE "area_kind" fusion_plating_jobs/models/fp_job_step.py | head
```
Record the confirmed names. **If `fp.job` has no direct `recipe_id`, use the field that resolves the recipe (check `fp_job.py` for `recipe_id` / `x_fc_recipe_id` / a compute).** The plan below assumes:
- `fp.job.recipe_id` (Many2one to the recipe node/header) — **substitute the real name everywhere if different.**
- `fp.job.step.area_kind`, `fp.job.step.qty_at_step`, `fp.job.step.qty_at_step_start/finish`, `fp.job.step.rack_id`.
- `fp.job.step.move`: `job_id, from_step_id, to_step_id, qty_moved, rack_id, transfer_type, moved_by_user_id, move_datetime`.
- `fusion.plating.rack`: `capacity`, `capacity_count`, `racking_state`.
- [ ] **Step 2: Confirm the area_kind column sequence** (for "least-advanced" later)
```bash
grep -n "_COLUMN_LABELS\|_COLUMN_SEQUENCE" fusion_plating_shopfloor/controllers/plant_kanban.py fusion_plating_jobs/models/fp_job.py
```
Record the ordered list `[receiving, masking, blasting, racking, plating, baking, de_racking, inspection, shipping]`.
---
### Task 1: `fp.rack.load` + `fp.rack.load.line` models + sequence + ACL
**Files:**
- Create: `fusion_plating/models/fp_rack_load.py`
- Modify: `fusion_plating/models/__init__.py` (add `from . import fp_rack_load`)
- Create: `fusion_plating/data/fp_rack_load_sequence.xml`
- Modify: `fusion_plating/security/ir.model.access.csv` (add rows)
- Modify: `fusion_plating/__manifest__.py` (add data file, bump version)
- Test: `fusion_plating/tests/test_rack_load.py` (+ register in `tests/__init__.py`)
- [ ] **Step 1: Write the failing test (model exists + qty_total compute + sequence)**
```python
# fusion_plating/tests/test_rack_load.py
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestRackLoad(TransactionCase):
def setUp(self):
super().setUp()
self.Load = self.env['fp.rack.load']
self.rack = self.env['fusion.plating.rack'].create({'name': 'TST-RACK-1', 'capacity': 60})
# A minimal job + racking step. Use existing helpers if present;
# otherwise create a bare job. Adjust required fields per fp.job.
self.job = self.env['fp.job'].create({'name': 'WO-TEST-1', 'qty': 100})
def test_create_and_qty_total(self):
load = self.Load.create({
'rack_id': self.rack.id,
'line_ids': [(0, 0, {'job_id': self.job.id, 'qty': 40})],
})
self.assertTrue(load.name.startswith('RACKLOAD/'))
self.assertEqual(load.qty_total, 40)
self.assertEqual(load.state, 'loading')
```
- [ ] **Step 2: Run it — expect FAIL** (`KeyError: 'fp.rack.load'`). Command: the Test command above, `--test-tags /fusion_plating:TestRackLoad`.
- [ ] **Step 3: Implement the models**
```python
# fusion_plating/models/fp_rack_load.py
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
class FpRackLoad(models.Model):
_name = 'fp.rack.load'
_description = 'Rack Load (parts on one physical rack)'
_inherit = ['mail.thread']
_order = 'id desc'
name = fields.Char(string='Reference', required=True, copy=False,
default=lambda self: _('New'))
rack_id = fields.Many2one('fusion.plating.rack', string='Rack',
required=True, tracking=True)
line_ids = fields.One2many('fp.rack.load.line', 'load_id', string='Work Orders')
qty_total = fields.Integer(string='Total Parts', compute='_compute_qty_total',
store=True)
current_step_id = fields.Many2one('fp.job.step', string='Current Step', tracking=True)
current_area_kind = fields.Char(string='Current Area',
compute='_compute_current_area_kind', store=True)
state = fields.Selection([
('loading', 'Loading'), ('loaded', 'Loaded'),
('running', 'Running'), ('unracked', 'Unracked'),
('cancelled', 'Cancelled'),
], default='loading', required=True, tracking=True)
tag_ids = fields.Many2many('fp.rack.tag', string='Tags')
company_id = fields.Many2one('res.company', default=lambda s: s.env.company)
@api.depends('line_ids.qty')
def _compute_qty_total(self):
for load in self:
load.qty_total = sum(load.line_ids.mapped('qty'))
@api.depends('current_step_id.area_kind')
def _compute_current_area_kind(self):
for load in self:
load.current_area_kind = load.current_step_id.area_kind or False
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fp.rack.load') or _('New')
return super().create(vals_list)
_qty_total_positive = models.Constraint(
'CHECK (qty_total >= 0)', 'Rack load quantity cannot be negative.')
class FpRackLoadLine(models.Model):
_name = 'fp.rack.load.line'
_description = 'Rack Load Line (one work order on a rack)'
load_id = fields.Many2one('fp.rack.load', required=True, ondelete='cascade')
job_id = fields.Many2one('fp.job', string='Work Order', required=True)
qty = fields.Integer(string='Parts', required=True, default=0)
part_catalog_id = fields.Many2one(related='job_id.part_catalog_id', store=True)
_qty_positive = models.Constraint(
'CHECK (qty >= 0)', 'Line quantity cannot be negative.')
```
Sequence:
```xml
<!-- fusion_plating/data/fp_rack_load_sequence.xml -->
<odoo>
<record id="seq_fp_rack_load" model="ir.sequence">
<field name="name">Rack Load</field>
<field name="code">fp.rack.load</field>
<field name="prefix">RACKLOAD/%(year)s/</field>
<field name="padding">4</field>
</record>
</odoo>
```
ACL rows (append to `fusion_plating/security/ir.model.access.csv`) — Technician r/w/c, Manager full:
```csv
access_fp_rack_load_tech,fp.rack.load.tech,model_fp_rack_load,fusion_plating.group_fp_technician,1,1,1,0
access_fp_rack_load_mgr,fp.rack.load.mgr,model_fp_rack_load,fusion_plating.group_fp_manager,1,1,1,1
access_fp_rack_load_line_tech,fp.rack.load.line.tech,model_fp_rack_load_line,fusion_plating.group_fp_technician,1,1,1,1
access_fp_rack_load_line_mgr,fp.rack.load.line.mgr,model_fp_rack_load_line,fusion_plating.group_fp_manager,1,1,1,1
```
Add `'data/fp_rack_load_sequence.xml'` to `__manifest__.py` `data`, bump `version`. Register the test in `tests/__init__.py`.
- [ ] **Step 4: Run the test — expect PASS.**
- [ ] **Step 5: Commit**`git add fusion_plating/models/fp_rack_load.py fusion_plating/data/fp_rack_load_sequence.xml fusion_plating/security/ir.model.access.csv fusion_plating/tests/test_rack_load.py fusion_plating/models/__init__.py fusion_plating/tests/__init__.py fusion_plating/__manifest__.py && git commit -m "feat(fusion_plating): add fp.rack.load + line models (racking phase 1)"`
---
### Task 2: Division API — add_rack / divide_equally / set_qty / remove_rack
Pure quantity math operating on a job's set of rack-loads at the racking step. This is the heart of the feature; full code + tests.
**Files:**
- Modify: `fusion_plating/models/fp_rack_load.py` (add class methods)
- Test: `fusion_plating/tests/test_rack_load.py` (add cases)
- [ ] **Step 1: Write failing tests for the division math (D4: remainder to first racks)**
```python
def _mk_loads(self, n, total):
"""Helper: split `total` parts of self.job across n loads equally."""
return self.env['fp.rack.load']._fp_split_job(self.job, total, n)
def test_divide_two_is_50_50(self):
loads = self._mk_loads(2, 100)
self.assertEqual(sorted(loads.mapped('qty_total')), [50, 50])
def test_divide_three_remainder_to_first(self):
loads = self._mk_loads(3, 100)
self.assertEqual(loads.mapped('qty_total'), [34, 33, 33])
def test_divide_four_equal(self):
loads = self._mk_loads(4, 100)
self.assertEqual(loads.mapped('qty_total'), [25, 25, 25, 25])
def test_add_rack_redivides(self):
loads = self._mk_loads(1, 100)
self.assertEqual(loads.mapped('qty_total'), [100])
loads2 = self.env['fp.rack.load']._fp_add_rack(self.job)
self.assertEqual(sorted(loads2.mapped('qty_total')), [50, 50])
def test_set_qty_manual_and_unassigned(self):
loads = self._mk_loads(2, 100) # 50/50
loads[0]._fp_set_qty(70)
# one load now 70; total assigned 120 must be rejected (> available)
with self.assertRaises(UserError):
loads[1]._fp_set_qty(50) # 70+50 > 100
```
- [ ] **Step 2: Run — expect FAIL** (`_fp_split_job` undefined).
- [ ] **Step 3: Implement the division API**
```python
@api.model
def _fp_equal_split(self, total, n):
"""Return a list of n ints summing to total; remainder to the first racks (D4)."""
if n < 1:
return []
base, rem = divmod(int(total), n)
return [base + 1 if i < rem else base for i in range(n)]
@api.model
def _fp_racking_step_for(self, job):
"""The job's Racking step (the parts source). Adjust the lookup to the
real racking-step detection (_fp_is_racking_step)."""
steps = job.step_ids if 'step_ids' in job._fields else \
self.env['fp.job.step'].search([('job_id', '=', job.id)])
return steps.filtered(lambda s: s._fp_is_racking_step())[:1]
@api.model
def _fp_racking_total(self, job):
"""Total parts available to rack for this job."""
step = self._fp_racking_step_for(job)
return int(step.qty_at_step) if step else int(job.qty)
@api.model
def _fp_job_loads(self, job):
return self.search([
('line_ids.job_id', '=', job.id),
('state', 'in', ('loading', 'loaded')),
])
@api.model
def _fp_split_job(self, job, total, n):
"""Create n fresh loads for `job` summing to `total`, equal split."""
existing = self._fp_job_loads(job)
existing.filtered(lambda l: not l.current_step_id).unlink()
qtys = self._fp_equal_split(total, n)
loads = self.env['fp.rack.load']
for q in qtys:
loads |= self.create({'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})]})
return loads
@api.model
def _fp_add_rack(self, job):
"""Add one rack and re-divide equally across all of the job's loads."""
total = self._fp_racking_total(job)
n = len(self._fp_job_loads(job)) + 1
return self._fp_split_job(job, total, max(n, 1))
@api.model
def _fp_divide_equally(self, job):
total = self._fp_racking_total(job)
n = max(len(self._fp_job_loads(job)), 1)
return self._fp_split_job(job, total, n)
def _fp_set_qty(self, qty):
"""Manual override of a single load's qty. Reject if it pushes the job's
total assigned over the available parts."""
self.ensure_one()
line = self.line_ids[:1]
if not line:
raise UserError(_('This rack has no work order line.'))
job = line.job_id
total = self.env['fp.rack.load']._fp_racking_total(job)
other = sum(self.env['fp.rack.load']._fp_job_loads(job).filtered(
lambda l: l != self).mapped('qty_total'))
if other + int(qty) > total:
raise UserError(_('Assigned %(a)s exceeds available %(t)s parts.')
% {'a': other + int(qty), 't': total})
line.qty = int(qty)
def _fp_remove_rack(self):
self.ensure_one()
if self.current_step_id:
raise UserError(_('Cannot remove a rack that has already moved.'))
self.unlink()
```
> Note: `_fp_racking_step_for` calls `_fp_is_racking_step()` (exists on `fp.job.step` in `fusion_plating_jobs`). `fp.rack.load` lives in `fusion_plating`, which loads before `fusion_plating_jobs`; guard with `if hasattr(step, '_fp_is_racking_step')` or move these helpers to a thin model extension in `fusion_plating_jobs`. **Decide at Task 0:** if `_fp_is_racking_step` isn't importable from core, put Task 2's `_fp_racking_step_for/_fp_racking_total` on an `fp.rack.load` extension in `fusion_plating_jobs/models/` instead.
- [ ] **Step 4: Run tests — expect PASS.**
- [ ] **Step 5: Commit**`git commit -am "feat(fusion_plating): rack-load division API (equal split + manual override)"`
---
### Task 3: `fp.job` integration — qty_racked / qty_unracked
**Files:**
- Create: `fusion_plating_jobs/models/fp_job_rack.py` (or add to `fp_job.py`)
- Modify: `fusion_plating_jobs/models/__init__.py`
- Test: `fusion_plating_jobs/tests/test_job_rack.py`
- [ ] **Step 1: Failing test**
```python
@tagged('post_install', '-at_install')
class TestJobRack(TransactionCase):
def test_qty_racked_unracked(self):
rack = self.env['fusion.plating.rack'].create({'name': 'R1', 'capacity': 60})
job = self.env['fp.job'].create({'name': 'WO-X', 'qty': 100})
self.env['fp.rack.load']._fp_split_job(job, 100, 2) # 50/50
self.assertEqual(job.qty_racked, 100)
self.assertEqual(job.qty_unracked, 0)
```
- [ ] **Step 2: Run — FAIL** (`qty_racked` undefined).
- [ ] **Step 3: Implement**
```python
# fusion_plating_jobs/models/fp_job_rack.py
from odoo import api, fields, models
class FpJob(models.Model):
_inherit = 'fp.job'
rack_load_line_ids = fields.One2many('fp.rack.load.line', 'job_id',
string='Rack Loads')
qty_racked = fields.Integer(compute='_compute_qty_racked')
qty_unracked = fields.Integer(compute='_compute_qty_racked')
@api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
def _compute_qty_racked(self):
for job in self:
active = job.rack_load_line_ids.filtered(
lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
job.qty_racked = sum(active.mapped('qty'))
total = self.env['fp.rack.load']._fp_racking_total(job)
job.qty_unracked = max(total - job.qty_racked, 0)
```
- [ ] **Step 4: Run — PASS.** **Step 5: Commit.**
---
### Task 4: Independent movement + De-Racking unrack
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job_rack.py` (movement methods on `fp.rack.load` via `_inherit`)
- Test: `fusion_plating_jobs/tests/test_job_rack.py` (add cases)
- [ ] **Step 1: Failing test (advance a load → creates per-line moves + sets position)**
```python
def test_advance_load_creates_move(self):
job = self.env['fp.job'].create({'name': 'WO-Y', 'qty': 60})
# need two steps: racking + plating. Build via the job's recipe/steps;
# for the unit test, create two fp.job.step rows directly.
Step = self.env['fp.job.step']
s_rack = Step.create({'job_id': job.id, 'name': 'Racking', 'sequence': 30})
s_plate = Step.create({'job_id': job.id, 'name': 'Plating', 'sequence': 40})
load = self.env['fp.rack.load']._fp_split_job(job, 60, 1)
load.current_step_id = s_rack
load._fp_advance_to(s_plate)
self.assertEqual(load.current_step_id, s_plate)
self.assertEqual(load.state, 'running')
mv = self.env['fp.job.step.move'].search([('rack_id', '=', load.rack_id.id)])
self.assertTrue(mv)
self.assertEqual(mv[0].qty_moved, 60)
```
- [ ] **Step 2: Run — FAIL.**
- [ ] **Step 3: Implement movement on `fp.rack.load`**
```python
class FpRackLoad(models.Model):
_inherit = 'fp.rack.load'
def _fp_advance_to(self, to_step):
"""Move this rack-load to `to_step`, writing one move row per line."""
Move = self.env['fp.job.step.move']
for load in self:
from_step = load.current_step_id
for line in load.line_ids:
Move.create({
'job_id': line.job_id.id,
'from_step_id': from_step.id if from_step else False,
'to_step_id': to_step.id,
'qty_moved': line.qty,
'rack_id': load.rack_id.id,
'transfer_type': 'step',
'moved_by_user_id': self.env.user.id,
})
load.current_step_id = to_step
load.state = 'running'
def _fp_unrack(self):
"""De-Racking: free the rack, mark unracked. Each line's parts continue
in their own job's flow (the moves already attributed qty per job)."""
for load in self:
load.state = 'unracked'
if load.rack_id:
load.rack_id.racking_state = 'empty'
```
- [ ] **Step 4: Run — PASS.** **Step 5: Commit.**
> Reuse the existing **Move Rack** tablet dialog for the operator-facing single/multi move; `_fp_advance_to` is the model API those endpoints call. The de-racking trigger: call `_fp_unrack()` from the De-Racking step's finish (wire in Task 6 controller or a `button_finish` hook — keep it in the controller for Phase 1).
---
### Task 5: Controllers `/fp/racking/*`
**Files:**
- Create: `fusion_plating_shopfloor/controllers/racking_controller.py`
- Modify: `fusion_plating_shopfloor/controllers/__init__.py`
- Test: manual (controller smoke via the panel in Task 6); optional python smoke with `pyflakes`.
- [ ] **Step 1: Implement endpoints** (JSONRPC, auth='user', run as the technician)
```python
# fusion_plating_shopfloor/controllers/racking_controller.py
from odoo import http
from odoo.http import request
from odoo.exceptions import UserError
class FpRackingController(http.Controller):
def _job(self, job_id):
return request.env['fp.job'].browse(int(job_id))
def _load_payload(self, job):
Load = request.env['fp.rack.load']
loads = Load._fp_job_loads(job)
total = Load._fp_racking_total(job)
return {
'ok': True,
'job_id': job.id,
'wo_name': job.display_wo_name,
'total': total,
'unassigned': max(total - sum(loads.mapped('qty_total')), 0),
'loads': [{
'id': l.id, 'name': l.name,
'rack_id': l.rack_id.id, 'rack_name': l.rack_id.name or '',
'rack_capacity': l.rack_id.capacity or 0,
'qty': l.qty_total,
'over_capacity': bool(l.rack_id.capacity and l.qty_total > l.rack_id.capacity),
'moved': bool(l.current_step_id),
} for l in loads],
}
@http.route('/fp/racking/load', type='jsonrpc', auth='user')
def load(self, job_id):
return self._load_payload(self._job(job_id))
@http.route('/fp/racking/add_rack', type='jsonrpc', auth='user')
def add_rack(self, job_id):
job = self._job(job_id)
try:
request.env['fp.rack.load']._fp_add_rack(job)
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
return self._load_payload(job)
@http.route('/fp/racking/divide_equally', type='jsonrpc', auth='user')
def divide_equally(self, job_id):
job = self._job(job_id)
request.env['fp.rack.load']._fp_divide_equally(job)
return self._load_payload(job)
@http.route('/fp/racking/set_qty', type='jsonrpc', auth='user')
def set_qty(self, load_id, qty):
load = request.env['fp.rack.load'].browse(int(load_id))
try:
load._fp_set_qty(qty)
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
return self._load_payload(load.line_ids[:1].job_id)
@http.route('/fp/racking/remove_rack', type='jsonrpc', auth='user')
def remove_rack(self, load_id):
load = request.env['fp.rack.load'].browse(int(load_id))
job = load.line_ids[:1].job_id
try:
load._fp_remove_rack()
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
return self._load_payload(job)
@http.route('/fp/racking/assign_rack', type='jsonrpc', auth='user')
def assign_rack(self, load_id, rack_id):
load = request.env['fp.rack.load'].browse(int(load_id))
rack = request.env['fusion.plating.rack'].browse(int(rack_id))
load.rack_id = rack.id
rack.racking_state = 'loaded'
return self._load_payload(load.line_ids[:1].job_id)
```
- [ ] **Step 2: pyflakes**`docker exec odoo-modsdev-app python3 -m pyflakes <file>` → no undefined names. **Step 3: Commit.**
---
### Task 6: Job Workspace Racking panel (OWL)
**Files:**
- Create: `fusion_plating_shopfloor/static/src/js/components/racking_panel.js`
- Create: `fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml`
- Create: `fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss`
- Modify: `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` (render `<RackingPanel>` when the WO is at the racking step)
- Modify: `fusion_plating_shopfloor/static/src/js/job_workspace.js` (import + register the component; pass `jobId`)
- Modify: `fusion_plating_shopfloor/__manifest__.py` (register the 3 asset files; bump version)
- [ ] **Step 1: Implement the OWL component** (standalone, `rpc` from `@web/core/network/rpc`, `static props`)
```javascript
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
export class RackingPanel extends Component {
static template = "fusion_plating_shopfloor.RackingPanel";
static props = ["jobId"];
setup() {
this.state = useState({ data: null, error: "" });
onWillStart(() => this.reload());
}
async reload() {
const d = await rpc("/fp/racking/load", { job_id: this.props.jobId });
if (d.ok) this.state.data = d; else this.state.error = d.error || "";
}
async addRack() { this._apply(await rpc("/fp/racking/add_rack", { job_id: this.props.jobId })); }
async divideEqually() { this._apply(await rpc("/fp/racking/divide_equally", { job_id: this.props.jobId })); }
async setQty(load, ev) {
const qty = parseInt(ev.target.value, 10) || 0;
this._apply(await rpc("/fp/racking/set_qty", { load_id: load.id, qty }));
}
async removeRack(load) { this._apply(await rpc("/fp/racking/remove_rack", { load_id: load.id })); }
_apply(d) { if (d.ok) this.state.data = d; else this.state.error = d.error || ""; }
}
```
```xml
<!-- racking_panel.xml -->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.RackingPanel">
<div class="o_fp_racking_panel" t-if="state.data">
<div class="o_fp_rkp_head">
<span class="o_fp_rkp_title">🧰 Racking</span>
<span class="o_fp_rkp_unassigned" t-att-class="state.data.unassigned ? 'has' : ''">
Unassigned: <t t-esc="state.data.unassigned"/> / <t t-esc="state.data.total"/>
</span>
</div>
<div t-if="state.error" class="o_fp_rkp_err" t-esc="state.error"/>
<t t-foreach="state.data.loads" t-as="load" t-key="load.id">
<div t-att-class="'o_fp_rkp_row' + (load.over_capacity ? ' over' : '')">
<span class="o_fp_rkp_rack" t-esc="load.rack_name || 'No rack'"/>
<input type="number" inputmode="numeric" class="form-control o_fp_rkp_qty"
t-att-value="load.qty" t-att-disabled="load.moved"
t-on-change="(ev) => this.setQty(load, ev)"/>
<span class="o_fp_rkp_cap" t-if="load.rack_capacity">
/ <t t-esc="load.rack_capacity"/>
</span>
<button class="btn btn-sm btn-light" t-att-disabled="load.moved"
t-on-click="() => this.removeRack(load)"></button>
</div>
</t>
<div class="o_fp_rkp_actions">
<button class="btn btn-primary" t-on-click="addRack">+ Add Rack</button>
<button class="btn btn-light" t-on-click="divideEqually">Divide Equally</button>
</div>
</div>
</t>
</templates>
```
SCSS: card surface using existing `$_ws-*` tokens (mirror `.o_fp_ws_rcv`); over-capacity row gets an amber left border. Register `RackingPanel` in `job_workspace.js` `components` and render it in the steps area when `state.data.job.is_at_racking` (add that flag to the workspace `/fp/workspace/load` payload, or check the active step's `area_kind === 'racking'`).
- [ ] **Step 2: Register assets + bump version.** **Step 3: Manual smoke** (see Task 7). **Step 4: Commit.**
---
### Task 7: Local deploy + manual smoke + verify
- [ ] **Step 1: Update + clear assets on local dev**
```bash
docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30
```
Expected: no ERROR/Traceback; "Modules loaded."
- [ ] **Step 2: Run the full test suite**
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_plating,/fusion_plating_jobs \
-u fusion_plating,fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
Expected: all green.
- [ ] **Step 3: Manual smoke (browser, http://localhost:8082):** open a WO at Racking in the Job Workspace → Racking panel shows 1 rack with all parts → +Add Rack → 50/50 → +Add Rack → 34/33/33 → edit a qty → Unassigned updates → assign a rack → move (via existing Move Rack) and confirm the load advances independently.
- [ ] **Step 4: Commit** any fixes. Do NOT deploy to entech yet — entech deploy is a separate, explicitly-confirmed step (new models + migration on a live DB).
---
## Self-Review
- **Spec coverage:** §3 model → Task 1; §4 division → Task 2; §3.3 job fields → Task 3; §5 movement + de-racking → Task 4; §7.3 endpoints → Task 5; §7.1 Job Workspace panel → Task 6. Phase-1 scope only (single WO / one line per load); §6 grouping + §7.2 station screen + §8 Plant Kanban are **Phase 2/3 (separate plans)** — intentionally deferred.
- **Placeholder scan:** the only deferred specifics are the confirmed field names (Task 0) and the racking-step lookup location (flagged in Task 2). No "TODO/handle edge cases" hand-waving in code steps.
- **Type consistency:** `_fp_split_job`, `_fp_add_rack`, `_fp_divide_equally`, `_fp_set_qty`, `_fp_remove_rack`, `_fp_advance_to`, `_fp_unrack`, `_fp_job_loads`, `_fp_racking_total` used consistently across Tasks 26; controller calls match.
## Notes for entech deployment (after local green)
- New models → `-u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor` on entech (creates tables, no destructive migration).
- Existing single `fp.job.step.rack_id` flow is untouched (back-compat).

View File

@@ -0,0 +1,869 @@
# WO Grouping by Recipe + Combined Multi-Part Certificate — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Group sale-order plating lines into one work order (`fp.job`) per distinct plating process, and make the Certificate of Conformance multi-part so a combined WO certifies every part truthfully.
**Architecture:** Spec → [docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md](../specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md). Lines whose resolved recipes share an identical *step structure* (and identical masking/bake toggles) collapse onto one `fp.job`. A new `fp.certificate.part` child model holds one row per SO line; `_fp_create_certificates` fills it; the CoC report loops it. The cert multi-part support lands **before** the grouping switch so flipping the grouping is never a compliance regression.
**Tech Stack:** Odoo 19 (Python ORM, QWeb PDF reports), modules `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`.
---
## Testing model (read this first — the env is unusual)
These modules **cannot install on the local Community box** (`fusion_plating` needs Enterprise deps; `installed=0` on `modsdev`). So:
- **Local per-task gate (always runnable):**
- Python: `docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/<path>.py`
(Adjust the `/mnt/odoo-modules/fusion_plating` prefix if your bind mount differs; `K:\Github\Odoo-Modules``/mnt/odoo-modules`, and the plating modules live under its `fusion_plating/` subdir.)
- XML: `docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/<path>.xml'); print('XML OK')"`
- **Odoo unit tests** (TransactionCase, committed as real artifacts): run on an **Enterprise env where `fusion_plating` is installed**`odoo-trial` (VM 316) if present, otherwise a throwaway **entech clone** (do NOT run `--test-enable -u` against prod `admin`). Command shape:
```
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
- **Live read-only smoke (safe on entech prod):** re-run the recipe-signature audit (Task 8) to confirm SO-30092/30083/30079/30071 collapse to one group each. Read-only — no writes.
- **Write-path smoke (clone / odoo-trial only):** create a test SO with same-structure lines, confirm, check one WO + one multi-part cert + render the CoC PDF.
Every "run the test" step below shows the command; if the Enterprise test env is not yet available, write + commit the test and run the suite at the Task 8 verification gate.
---
## File structure
| File | Module | Responsibility |
|------|--------|----------------|
| `fusion_plating_certificates/models/fp_certificate_part.py` | certificates | NEW — one row per part on a cert. |
| `fusion_plating_certificates/models/fp_certificate.py` | certificates | ADD `part_line_ids` O2M. |
| `fusion_plating_certificates/models/__init__.py` | certificates | import new model. |
| `fusion_plating_certificates/security/ir.model.access.csv` | certificates | ACL for `fp.certificate.part`. |
| `fusion_plating_certificates/views/fp_certificate_views.xml` | certificates | "Parts" notebook page. |
| `fusion_plating_certificates/__manifest__.py` | certificates | version bump. |
| `fusion_plating_jobs/models/fp_job.py` | jobs | requirement union + part-line build in `_fp_create_certificates`. |
| `fusion_plating_jobs/models/sale_order.py` | jobs | grouping signature + key (the switch). |
| `fusion_plating_jobs/report/report_fp_job_traveller.xml` | jobs | Item Information loops all parts. |
| `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py` | jobs | backfill one part-line per existing cert. |
| `fusion_plating_jobs/__manifest__.py` | jobs | version bump. |
| `fusion_plating_jobs/tests/test_wo_recipe_grouping.py` | jobs | NEW — signature + grouping tests. |
| `fusion_plating_jobs/tests/test_combined_cert_creation.py` | jobs | NEW — multi-part cert creation tests. |
| `fusion_plating_reports/report/report_coc.xml` | reports | parts-table loop. |
| `fusion_plating_reports/__manifest__.py` | reports | version bump. |
> **Migration location note:** the spec listed the backfill under `fusion_plating_certificates`. It is **moved to `fusion_plating_jobs`** here because the backfill reads `x_fc_job_id` (a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). The `fp.certificate.part` table is created by the certificates upgrade, which Odoo runs first.
**Build order:** cert model → cert form → cert creation → CoC report → traveller → **grouping switch (last)** → migration + verify. This way the multi-part cert is ready before any WO ever carries multiple parts.
---
### Task 1: `fp.certificate.part` model + `part_line_ids` + ACL
**Files:**
- Create: `fusion_plating_certificates/models/fp_certificate_part.py`
- Modify: `fusion_plating_certificates/models/fp_certificate.py` (add O2M near the existing `thickness_reading_ids` at line 87)
- Modify: `fusion_plating_certificates/models/__init__.py`
- Modify: `fusion_plating_certificates/security/ir.model.access.csv`
- [ ] **Step 1: Create the model**
```python
# fusion_plating_certificates/models/fp_certificate_part.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# One row per part on a Certificate of Conformance. A work order can
# cover several parts that share the same plating process (see
# fusion_plating_jobs sale_order._fp_line_group_key); the combined CoC
# lists each part with its own identity + spec + quantities.
from odoo import fields, models
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).')
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec')
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')
```
- [ ] **Step 2: Register the import**
In `fusion_plating_certificates/models/__init__.py`, add (alphabetical / near the other cert imports):
```python
from . import fp_certificate_part
```
- [ ] **Step 3: Add the O2M on `fp.certificate`**
In `fusion_plating_certificates/models/fp_certificate.py`, immediately after the `thickness_reading_ids` field (line 87-89):
```python
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
```
- [ ] **Step 4: Add ACL rows**
Append to `fusion_plating_certificates/security/ir.model.access.csv` (mirror the existing `fp.certificate` group grants):
```csv
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
```
- [ ] **Step 5: Static checks**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
```
Expected: no output (clean).
- [ ] **Step 6: Commit**
```bash
git add fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py \
fusion_plating/fusion_plating_certificates/models/fp_certificate.py \
fusion_plating/fusion_plating_certificates/models/__init__.py \
fusion_plating/fusion_plating_certificates/security/ir.model.access.csv
git commit -m "feat(fusion_plating_certificates): add fp.certificate.part child model + ACL"
```
---
### Task 2: "Parts" page on the certificate form
**Files:**
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml` (notebook at line 154)
- [ ] **Step 1: Add the Parts page as the first notebook page**
Insert immediately after `<notebook>` (line 154), before the existing `<page string="Thickness Readings" ...>`:
```xml
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
```
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
git commit -m "feat(fusion_plating_certificates): Parts page on certificate form"
```
---
### Task 3: `_fp_create_certificates` fills part-lines + requirement union
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py` (`_resolve_required_cert_types` ~line 611; `_fp_create_certificates` build of `vals` before `Cert.create(vals)` at line 2784)
- Test: `fusion_plating_jobs/tests/test_combined_cert_creation.py`
- [ ] **Step 1: Write the failing test**
```python
# fusion_plating_jobs/tests/test_combined_cert_creation.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
```
- [ ] **Step 2: Run it (Enterprise test env) — expect FAIL**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: FAIL — `cert.part_line_ids` is empty (creation doesn't fill it yet).
- [ ] **Step 3: Add helper methods on `fp.job`**
Add near `_fp_create_certificates` in `fusion_plating_jobs/models/fp_job.py`:
```python
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc or '',
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
```
- [ ] **Step 4: Fill `part_line_ids` in `_fp_create_certificates`**
In `_fp_create_certificates`, immediately before `cert = Cert.create(vals)` (line 2784), add:
```python
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
```
- [ ] **Step 5: Requirement union over all parts**
In `_resolve_required_cert_types` (Step 1, ~line 611-642), replace the single-part read with a union across all parts on the job. Change the Step-1 block so `wanted` is the union of each line's part-level requirement (falling back to the partner inherit set computed once):
```python
# ---- Step 1 — partner + part baseline (union across all parts) ----
def _partner_inherit_set():
s = set()
p = self.partner_id
if p:
if p.x_fc_send_coc:
s.add('coc')
if p.x_fc_send_thickness_report:
s.add('thickness_report')
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
s.add('nadcap_cert')
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
s.add('mill_test')
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
s.add('customer_specific')
return s
def _explicit_set(req):
return {
'none': set(), 'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
wanted = set()
inherit = None
for part in (parts or [False]):
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
```
Leave Step 2 (recipe suppression) and Step 3 (CoC/thickness bundling) unchanged — they already operate on `wanted`.
- [ ] **Step 6: Run the test — expect PASS**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: PASS.
- [ ] **Step 7: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/fp_job.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
```
Expected: clean.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
git commit -m "feat(fusion_plating_jobs): multi-part cert creation + requirement union"
```
---
### Task 4: CoC report renders the parts table as a loop
**Files:**
- Modify: `fusion_plating_reports/report/report_coc.xml` (tbody at lines 297-321)
- [ ] **Step 1: Replace the single hard-coded row with a loop + fallback**
Replace the `<tbody>...</tbody>` block (lines 297-322) with:
```xml
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference">
<br/><em t-esc="pl.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<tr t-if="not doc.part_line_ids">
<td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div>
<div><t t-esc="pid[1] or '-'"/></div>
<div><t t-esc="pid[2] or '-'"/></div>
</td>
<td>
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference">
<br/><em t-esc="doc.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="doc.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="doc.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</tbody>
```
> Keep `page-break-inside: avoid` on the parent table (line 271-272) unchanged. Each part row is short; the table-level rule already prevents mid-row splits for the typical 1-4 part case.
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_reports/report/report_coc.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_reports/report/report_coc.xml
git commit -m "feat(fusion_plating_reports): CoC parts table loops part_line_ids"
```
---
### Task 5: Traveller lists every part in the batch
**Files:**
- Modify: `fusion_plating_jobs/report/report_fp_job_traveller.xml` (Item Information block, ~lines 116-160)
- [ ] **Step 1: Loop the plating lines in the Item Information cell**
The Item Information `<td>` currently renders `job.part_catalog_id` once (singular). Wrap the per-part rows in a loop over the job's plating lines, falling back to the singular part when no lines are linked. Replace the singular part-number / revision / material / name reads (lines ~127-157) with:
```xml
<t t-set="trav_lines"
t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else job.browse([])"/>
<t t-if="not trav_lines and 'part_catalog_id' in job._fields and job.part_catalog_id">
<t t-set="trav_parts" t-value="[job.part_catalog_id]"/>
</t>
<t t-else="">
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id')"/>
</t>
<t t-foreach="trav_parts" t-as="tp">
<div style="margin-bottom: 2px;">
<strong t-esc="tp.part_number or '—'"/>
<t t-if="'revision' in tp._fields and tp.revision">
<span> Rev <t t-esc="tp.revision"/></span>
</t>
<t t-if="'base_material' in tp._fields and tp.base_material">
<span> · <t t-esc="tp.base_material"/></span>
</t>
<span> · <t t-esc="tp.name or '—'"/></span>
</div>
</t>
```
> This preserves the existing field reads (`part_number`, `revision`, `base_material`, `name`) but emits one line per part. The routing/process table below (one shared recipe) is unchanged. Verify the surrounding `<td>`/column structure still balances after the edit — keep the edit inside the existing Item Information cell.
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
git commit -m "feat(fusion_plating_jobs): traveller lists all parts in the batch"
```
---
### Task 6: Grouping by recipe structural signature (the switch)
**Files:**
- Modify: `fusion_plating_jobs/models/sale_order.py` (`_fp_auto_create_job` groups block, lines 439-470)
- Test: `fusion_plating_jobs/tests/test_wo_recipe_grouping.py`
- [ ] **Step 1: Write the failing tests**
```python
# fusion_plating_jobs/tests/test_wo_recipe_grouping.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
def _recipe(self, name, step_names):
root = self.Node.create({'name': name, 'node_type': 'recipe'})
seq = 10
for sn in step_names:
self.Node.create({
'name': sn, 'node_type': 'step',
'parent_id': root.id, 'sequence': seq})
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
```
- [ ] **Step 2: Run them — expect FAIL**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: FAIL — `_fp_recipe_signature` does not exist yet.
- [ ] **Step 3: Add the signature helpers on `sale.order`**
In `fusion_plating_jobs/models/sale_order.py`, add these methods (near `_fp_resolve_recipe_for_line`):
```python
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit)."""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line):
"""WO grouping key. Lines with the same key ride one work order."""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
return ('recipe',
self._fp_recipe_signature(recipe),
self._fp_line_express_signature(line))
```
- [ ] **Step 4: Replace the grouping loop**
In `_fp_auto_create_job`, replace the `groups`-building block (lines 445-470, the `unrecipe_idx`/5-tuple-key logic) with:
```python
# Group by recipe structural signature (+ per-line masking/bake
# toggles). Lines whose recipes have identical steps collapse onto
# one WO; no-recipe lines stay separate. See spec
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
groups = {}
for line in plating_lines:
key = self._fp_line_group_key(line)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
Everything after (the `ordered_keys = sorted(...)` block at line 473 onward) is unchanged — it still derives `n_groups`, names WOs `WO-<parent>` / `WO-<parent>-NN`, and builds one job per group carrying `sale_order_line_ids`.
- [ ] **Step 5: Run the tests — expect PASS**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: PASS (4 tests).
- [ ] **Step 6: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/sale_order.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
```
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/models/sale_order.py \
fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
git commit -m "feat(fusion_plating_jobs): group WOs by recipe step structure"
```
---
### Task 7: Migration backfill + version bumps
**Files:**
- Create: `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py`
- Modify: `fusion_plating_jobs/__manifest__.py` (`19.0.12.1.6` → `19.0.12.2.0`)
- Modify: `fusion_plating_certificates/__manifest__.py` (`19.0.9.3.0` → `19.0.10.0.0`)
- Modify: `fusion_plating_reports/__manifest__.py` (`19.0.11.34.0` → `19.0.11.35.0`)
- [ ] **Step 1: Write the backfill migration**
```python
# fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'serial': pid[2] or '',
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)
```
- [ ] **Step 2: Bump versions**
`fusion_plating_jobs/__manifest__.py`: `'version': '19.0.12.1.6',` → `'version': '19.0.12.2.0',`
`fusion_plating_certificates/__manifest__.py`: `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',`
`fusion_plating_reports/__manifest__.py`: `'version': '19.0.11.34.0',` → `'version': '19.0.11.35.0',`
- [ ] **Step 3: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
```
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_certificates/__manifest__.py \
fusion_plating/fusion_plating_reports/__manifest__.py
git commit -m "feat(fusion_plating): cert backfill migration + version bumps"
```
---
### Task 8: Verification (Enterprise env + read-only entech smoke)
**Files:** none (verification only).
- [ ] **Step 1: Full suite on the Enterprise test env**
Run:
```
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs,fusion_plating_certificates,fusion_plating_reports \
--stop-after-init --http-port=0 --gevent-port=0
```
Expected: exit 0; the new grouping + cert tests pass; no regressions in existing `fusion_plating_jobs` tests.
- [ ] **Step 2: Read-only signature re-run on entech (prod-safe)**
Confirm the four real orders collapse. In `odoo shell -d admin` on entech (read-only — no commit):
```python
SO = env['sale.order']
for name in ('SO-30092', 'SO-30083', 'SO-30079', 'SO-30071'):
so = SO.search([('name', '=', name)], limit=1)
if not so:
continue
lines = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
keys = {SO._fp_line_group_key(l) for l in lines}
print(name, 'lines=%d' % len(lines), 'groups=%d' % len(keys))
# Expect: each prints groups=1
```
- [ ] **Step 3: Write-path smoke (clone / odoo-trial — NOT prod)**
On a non-prod Enterprise DB: create an SO with 3 lines (2 sharing a structurally-identical recipe, 1 different) for a partner with `x_fc_send_coc=True`; confirm it; verify (a) **2** `fp.job` records, (b) the merged job has 2 `sale_order_line_ids`, (c) closing the merged job produces **one** CoC with **2** `part_line_ids`, (d) the rendered CoC PDF shows 2 part rows, (e) a migrated legacy single-part cert still renders one row.
- [ ] **Step 4: Mark plan complete**
All boxes checked, suite green, entech smoke shows `groups=1` for the four orders → ready to deploy (entech upgrade of the three modules, per the standard deploy recipe in CLAUDE.md).
---
## Self-review (completed by plan author)
- **Spec coverage:** grouping signature (Task 6) ✓; combined cert + per-part lines (Tasks 1-3) ✓; CoC report loop (Task 4) ✓; traveller (Task 5) ✓; migration backfill (Task 7) ✓; requirement union (Task 3) ✓; locked decisions (NC=0 editable, union lists all parts, masking/bake split) encoded in Tasks 3 & 6 ✓. Phase 2 (per-part thickness, per-part stickers) intentionally out of scope.
- **Placeholder scan:** no TBD/TODO; every code step shows complete code; `<enterprise_test_db>` is an explicit env parameter (documented in the Testing model), not a code placeholder.
- **Type/name consistency:** `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key` (Task 6) match their uses; `fp.certificate.part` fields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7); `part_line_ids` used consistently across Tasks 1-4 & 7.
- **Known limitation (documented in code):** two same-structure lines that both have bake instructions but different text merge; the shared bake step carries the last applied line's text. Acceptable for Phase 1.

View File

@@ -0,0 +1,129 @@
# Box-Level Tracking + Job Sticker Redesign — Design Spec
Date: 2026-06-03
Status: Approved (brainstormed with client), implementation in progress.
## Summary
Two coupled deliverables:
1. **Job sticker redesign** (thermal-label-friendly, 6×4 in / 152×102 mm):
- **Internal Job Sticker → Layout A** (stacked: identity band + full-width
instructions), printed **one per job**.
- **External Job Sticker → Layout B** (left identity rail + tall instructions
column), printed **one per box**, carrying the **box identity** (BOX n/N)
and a **per-box QR**. Shows the **factory logo** (`env.company.logo`).
2. **Box-level tracking**: a new `fp.box` registry, one record per received box,
auto-created at receiving, with a status workflow and per-box scannable QR.
## Decisions (locked with client)
| Q | Decision |
|---|---|
| Label size | Keep 6×4 in (152×102 mm). |
| Redesign goals | Readability/scan-speed + thermal print quality (no grey fills — solid-black bands + knockout white text; thick rules; bold sans). |
| Masking on label | **MASK badge** (on/off flag) when `sale.order.line.x_fc_masking_enabled` is true. No detail text. |
| Baking on label | **BAKE block** showing `sale.order.line.x_fc_bake_instructions` text, only when present. Also a BAKE flag for at-a-glance. |
| Notes source | Internal = `x_fc_internal_description`; External = SO line `name` (customer-facing). |
| Long notes | Notes-dominant zone, **length-tiered font shrink** to keep to **one label**, clip with "…see traveller" only in the extreme. |
| Factory logo | On **External only** (header), from `env.company.logo``logo_web` → company partner image. Internal stays clean. Thermal caveat: prefer a mono/high-contrast logo. |
| Box tracking depth | **Box registry** — per-box record, status, scannable QR. (Not box-contents.) |
| Internal copies | **One per job.** |
| External copies | **One per box.** |
| Box QR | **Per-box** — encodes `/fp/box/<id>`. |
## Label layouts (approved mockups)
Both labels: outer 0.9 mm border, `overflow:hidden` single-page guard, dynamic
blocks render only when their field has content.
**Layout A (Internal, per job):** full-width stacked rows —
`[logo | WO# band + INTERNAL tag | QR]``Part# + MASK/BAKE flags`
one-line field strip `Customer · PO · Qty · Due · Thk``BAKE` block →
`NOTES` (full width, `x_fc_internal_description`, length-tiered, bottom padding).
**Layout B (External, per box):** absolute two-column —
- Left rail (50 mm): `logo` → black band `WORK ORDER <wo> | BOX n / N`
`MASK/BAKE` flags → per-box QR → `Part#``Customer``PO/Qty``Due/Thk`.
- Right column: `BAKE` block → `NOTES` (customer description, length-tiered).
- Full-height divider (rail `border-right`). CUSTOMER copy.
Reference mockups (Chrome-rendered, true 6×4):
`~/Downloads/fusion_sticker_concepts/Sticker-A-Internal-LongNotes.*`,
`Sticker-B-External.*`. Final proof renders through entech wkhtmltopdf.
## `fp.box` model (fusion_plating_receiving)
| Field | Type | Notes |
|---|---|---|
| `name` | Char | Sequence, e.g. `BOX/<wo-or-recv>/01`. |
| `box_number` | Integer | n (1..N). |
| `box_count` | Integer | N (related/snapshot of receiving `box_count_in`). |
| `receiving_id` | M2O `fp.receiving` | Origin. ondelete cascade. |
| `sale_order_id` | M2O `sale.order` | Related from receiving. |
| `job_id` | M2O `fp.job` | Resolved (single-job SO = that job; multi-job = first/SO-level, see edge cases). |
| `partner_id` | M2O `res.partner` | Related (customer). |
| `state` | Selection | `received → racked → in_process → packed → shipped` (+ `lost`/`cancelled`). |
| `qr` | Binary/compute | Encodes `<base_url>/fp/box/<id>`. |
| `location_note` | Char | Optional free text "where is it now". |
| `scan_event_ids` | (phase 2) | Per-scan log — deferred. |
Constraints: `(receiving_id, box_number)` unique. Append-only-ish; state advances.
## Auto-create at receiving
When `fp.receiving.box_count_in = N` is set and the receiving is confirmed
(state hook — reuse the existing box-count chatter point at
`fp_receiving.py:~1191`), create/sync N `fp.box` rows (1..N), linked to the
receiving + resolved job. **Idempotent**: changing N adds/removes trailing rows
(never renumbers existing tracked boxes). Manager can regenerate.
## Scanning
- Controller route `/fp/box/<int:box_id>` → resolves the box, shows its job /
status, allows advancing state (received→…→shipped). Tie into the existing
shopfloor scan wedge (`request.env.user` attribution — no `tablet_tech_id`).
- **Reconciliation**: helper flags a receiving/job whose boxes haven't all
reached `shipped` (so none are lost — matches the "ship back in the same
boxes" Sub-8 rule).
## Label binding
- **External job sticker** (`fusion_plating_jobs.report_fp_job_sticker_template`):
iterate the job's `fp.box` records → **one label per box** (Layout B), each
with its `box_number/box_count` + per-box QR (`/fp/box/<id>`). Replaces the
current `range(box_count_in)` loop in `report_fp_wo_sticker_inner`. When a job
has no `fp.box` rows yet, fall back to a single label (BOX 1/1).
- **Internal job sticker** (`report_fp_job_sticker_internal_template`): **one per
job** (Layout A), job QR (`/fp/job/<id>`), no box loop.
- Shared inner keeps the 100-label hard safety cap (defense-in-depth from the
WO-30072 OOM fix).
## UI
- Boxes list + kanban (group by `state`) under **Operations**; form with state
buttons + scan QR.
- Smart buttons: box count on `fp.receiving` and `fp.job` forms.
## Module placement
- Model + auto-create + views/menu/ACL → `fusion_plating_receiving`.
- Scan controller → `fusion_plating_receiving` (or shopfloor).
- Label templates → `fusion_plating_jobs` (job stickers) + shared inner in
`fusion_plating_reports`.
## Edge cases / open
- **Multi-job SO** (one SO line → multiple jobs via serial/thickness grouping):
boxes are physical (per shipment/receiving). MVP links a box to the SO's
primary job; the external sticker prints the SO's boxes. Revisit if a real
multi-job-per-box case appears.
- **Box ↔ part for multi-part SO**: out of MVP (registry, not contents).
- Per-box qty/contents = future "registry + contents" upgrade.
## Deploy / verify
entech (LXC 111 / pve-worker5), `-u fusion_plating_receiving fusion_plating_jobs
fusion_plating_reports` with the revert-on-failure guard. Verify: render both
stickers for a real job through wkhtmltopdf; confirm auto-create on a test
receiving; scan a box id.

View File

@@ -0,0 +1,133 @@
# Multi-Rack Splitting + Work-Order Grouping at Racking — Design
**Date:** 2026-06-03
**Status:** Approved (design sign-off 2026-06-03)
**Modules touched:** `fusion_plating` (core: rack-load models), `fusion_plating_jobs` (movement / partial-order integration), `fusion_plating_shopfloor` (UI surfaces + controllers), `fusion_plating_reports` (rack travel ticket reuse)
## 1. Problem / Goal
At the **Racking** step, operators load a job's parts onto physical racks before plating. Today a step links to exactly **one** rack (`fp.job.step.rack_id`, single Many2one) and there is **no model for partial parts-per-rack** or **multiple work orders sharing a rack**. Operators need to:
1. **Split a job across multiple racks.** Default: all parts on one rack. An **"+ Add Rack"** button divides the quantity equally (100 → 50/50 → 34/33/33 → 25×4…). The operator can then **manually override** any individual rack's quantity.
2. **Move racks independently** through the rest of the line (Plating → Baking → De-Racking) — partial-order flow, but rack-aware. The operator chooses which rack(s) advance.
3. **Group multiple work orders on one rack** when they run the **identical recipe + spec** (any customer), for line efficiency — e.g. WO-A (20 ENP parts) + WO-B (10 ENP parts) on one rack, processed together, then separated at De-Racking.
## 2. Locked Decisions (from brainstorm 2026-06-03)
| # | Decision |
|---|----------|
| D1 | **Rack movement = independent, operator's choice.** Each rack is its own trackable unit; it can move ahead on its own, or the operator can move several at once. |
| D2 | **Grouping eligibility = identical process + spec.** Only WOs with the same resolved recipe AND same coating spec / thickness target may share a rack. Different customers are allowed. Mismatched recipe/spec is **blocked**. |
| D3 | **Two UI surfaces.** (a) A per-WO **Racking panel** on the Job Workspace (the split case). (b) A dedicated **Racking Station** shop-floor screen listing all WOs at Racking, with split controls *and* cross-WO grouping. Both drive the same model + endpoints. |
| D4 | **Division remainder** goes to the first rack(s): `base = total // N`, the first `total % N` racks get `base + 1`. Total always equals the parts available. |
| D5 | **Capacity = soft warning.** Each rack shows `assigned / capacity`; over-capacity is an amber warning, never a hard block. |
| D6 | **Plant Kanban = one card per job** with a small **rack rollup** ("3 racks · 1 Baking, 2 Plating"). The job card sits in the column of its **least-advanced** rack-load (a WO isn't "done" until every rack clears). Per-rack detail lives on the Racking screen / a card drill-down — NOT as separate board cards. |
## 3. Data Model
### 3.1 `fp.rack.load` (new, in `fusion_plating`)
"Parts loaded on one physical rack." First-class, moves through the workflow independently.
| Field | Type | Notes |
|---|---|---|
| `name` | Char | Sequence `RACKLOAD/YYYY/NNNN` |
| `rack_id` | Many2one `fusion.plating.rack` | The physical rack |
| `line_ids` | One2many `fp.rack.load.line` (inverse `load_id`) | Per-WO allocation (1 line = single WO; 2+ = grouped) |
| `qty_total` | Integer (compute, stored) | `sum(line_ids.qty)` |
| `recipe_id` | Many2one (recipe ref) | The shared recipe (all lines must match) — for grouping eligibility + display |
| `spec_key` | Char (compute, stored) | Normalised spec/thickness signature used to enforce D2 grouping |
| `current_step_id` | Many2one `fp.job.step` | The step the rack-load is parked at (drives independent position) |
| `current_area_kind` | Char (compute, stored) | From `current_step_id.area_kind` — for the Plant Kanban column |
| `state` | Selection | `loading``loaded``running``unracked` (→ `cancelled`) |
| `tag_ids` | Many2many `fp.rack.tag` | Reuse existing rack tags (Rush / Hold for QC) |
| `company_id` | Many2one | Standard |
| chatter | mail.thread | Audit |
Constraints: a rack-load's `line_ids` must all share `recipe_id` + `spec_key` (D2); `qty_total` must be ≥ 1; `rack_id` unique among non-unracked loads (a physical rack holds one active load at a time).
### 3.2 `fp.rack.load.line` (new, in `fusion_plating`)
| Field | Type | Notes |
|---|---|---|
| `load_id` | Many2one `fp.rack.load`, required, ondelete cascade | |
| `job_id` | Many2one `fp.job`, required | The work order whose parts are on this rack |
| `qty` | Integer, required | Parts of this job on this rack |
| `part_catalog_id` | Many2one (related from job) | Display |
| `recipe_id` / `spec_key` | related/compute from job | Used to enforce D2 |
### 3.3 Job ↔ rack-load relationships (on `fp.job`, in `fusion_plating_jobs`)
- `rack_load_line_ids` (One2many to `fp.rack.load.line`) — all loads carrying this job's parts.
- `qty_racked` (compute) = sum of this job's load-line qtys — how many of the job's parts are on racks.
- `qty_unracked` (compute) = `qty_at_racking_step qty_racked` — parts not yet assigned to a rack (the "Unassigned" counter).
## 4. Division Math (the "+ Add Rack" behaviour)
- Default state: **1 rack-load, line.qty = full racking quantity**.
- **+ Add Rack** → create one more rack-load and **re-divide equally** across all current loads (D4): `base = total // N`; first `total % N` loads get `base + 1`. This overwrites all line qtys (the simple behaviour: "add 4th rack → divide by 4").
- **Divide Equally** button → same as above without adding a rack (re-balance current N).
- **Manual qty edit** on a rack → updates that load's line qty; the **Unassigned: N** counter recomputes (`total Σ assigned`). Manual edits persist until the next *Add Rack* / *Divide Equally*. Sum may not exceed `total` (validation). Sum < total is allowed (operator may rack in waves) and shown as Unassigned.
- **Remove Rack** → only when its load hasn't moved past Racking; its qty returns to Unassigned.
## 5. Independent Movement + Partial-Order Integration
- Movement reuses the existing **move log** `fp.job.step.move`. When a rack-load advances from step A → B, create **one move row per line** (per job): `from_step_id`, `to_step_id`, `qty_moved = line.qty`, `rack_id = load.rack_id`, `transfer_type = 'step'`. This keeps the existing `qty_at_step` partial-order compute correct and rack-aware.
- The rack-load's `current_step_id` is set to the destination on commit (explicit position for the independent-movement UI), and `state` flips `loaded → running`.
- The operator can move **one** load or **select several** to move together (D1). Reuse / extend the existing **Move Rack** tablet dialog (`move_rack_dialog.js` + `/fp/tablet/move_rack/*`) so a rack-load moves as a unit; the multi-select batch move is a thin wrapper.
- **De-Racking** = unrack. When a rack-load reaches the De-Racking step and is unracked: set `state = unracked`, free the physical rack (`rack.racking_state = 'empty'`), and each line's `qty` returns to **its own** job's downstream flow (inspection → cert → shipping). Grouped WOs separate cleanly here — each job continues with its own parts/qty.
## 6. Work-Order Grouping (D2)
- On the Racking Station screen, eligible WOs at Racking (same `recipe_id` + `spec_key`, any customer) can be **pulled onto a shared rack-load** → adds a `fp.rack.load.line` for the second job.
- Eligibility is enforced server-side: adding a line whose job's recipe/spec differs from the load's is rejected with a clear message.
- A grouped rack-load moves as one unit (§5); at De-Racking each line returns to its job (§5).
## 7. UI Surfaces
### 7.1 Job Workspace → Racking panel (per-WO) — `fusion_plating_shopfloor`
- Appears on the Job Workspace when the WO is at the Racking step (mirrors the existing Receiving card pattern).
- Shows: total parts, **Unassigned: N**, a list of rack-loads each with `[rack picker] [qty input] [assigned/capacity bar] [remove]`, **+ Add Rack** and **Divide Equally** buttons.
- Split / qty-edit only (single WO). Grouping is not done here.
### 7.2 Racking Station screen (new) — `fusion_plating_shopfloor`
- New OWL client action + menu under Shop Floor.
- Lists all WOs currently at the Racking step (grouped by recipe/spec for grouping visibility).
- Per-WO split controls (same as 7.1) **plus** "Combine onto rack" to pull an eligible WO onto another's rack-load.
- Shows rack capacity bars + over-capacity warnings.
### 7.3 Shared controller endpoints — `fusion_plating_shopfloor/controllers`
- `/fp/racking/load` (GET context for a WO or the station)
- `/fp/racking/add_rack` / `divide_equally` / `set_qty` / `remove_rack`
- `/fp/racking/assign_rack` (pick/scan the physical rack for a load — reuse `/rack/list_empty` + `/rack/scan_qr`)
- `/fp/racking/group` (add an eligible WO's line to a load) / `ungroup`
- `/fp/racking/move` (advance one or more rack-loads to the next step — wraps the move-log writes)
All run as `request.env.user` (the technician) reusing existing rack/move ACLs.
## 8. Plant Kanban Representation (D6)
- One card per job. Card column = area of the job's **least-advanced** rack-load (`min` over `rack_load_line_ids.load_id.current_area_kind` by column sequence), falling back to today's `active_step_id.area_kind` when the job has no rack-loads.
- Card shows a compact **rack rollup** chip ("3 racks · 1 Baking, 2 Plating"). Tapping the chip / card opens a per-rack drill-down (or routes to the Racking screen).
- No new board columns; no per-rack board cards.
## 9. Phasing (single spec, built in order)
1. **Phase 1 — Split + independent movement.** `fp.rack.load` + `fp.rack.load.line`, division math, move-log integration, De-Racking unrack, Job Workspace Racking panel. Single-WO only (one line per load).
2. **Phase 2 — WO grouping + Racking Station screen.** Multi-line loads, eligibility enforcement, the dedicated cross-WO surface.
3. **Phase 3 — Plant Kanban rollup + drill-down.**
## 10. Integration Points / Reuse
- `fusion.plating.rack` (capacity, racking_state, tags) — reused; rack-load references it.
- `fp.job.step.move` / `qty_at_step` partial-order compute — reused, now rack-aware.
- `move_rack_dialog.js` + `/fp/tablet/move_rack/*` + `/rack/list_empty` + `/rack/scan_qr` — reused/extended.
- Rack Travel Ticket PDF (`report_fp_rack_travel`) — reused (print a load's ticket).
- `_fp_is_racking_step` / racking inspection gate — unchanged; rack-loads are created at the racking step.
## 11. Edge Cases / Rules
- Sum of load qtys may be **< total** (rack in waves); the remainder shows as Unassigned and can be racked later.
- A load can't be removed/edited once it has moved past Racking.
- One physical rack = one active (non-unracked) load at a time.
- Over-capacity = soft amber warning only.
- Cancelling a job cascades its load lines; a load with no remaining lines is cancelled.
- Migration: existing single `fp.job.step.rack_id` assignments are left as-is (legacy); new flow uses rack-loads. No destructive backfill.
## 12. Out of Scope (this spec)
- Auto-suggesting which WOs to group (operator-driven only).
- Rack capacity *planning*/optimisation.
- Changing the De-Racking inspection model.
- Reworking the legacy `rack_id`-on-step flow (kept for back-compat).

View File

@@ -0,0 +1,425 @@
# WO Grouping by Recipe + Combined Multi-Part Certificate
**Date:** 2026-06-03
**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`
**Author:** Gurpreet (Nexa Systems Inc.)
**Status:** Approved — ready for implementation plan
## Summary
Today a confirmed sale order with N plating lines creates N work orders
(`fp.job` / "WO-NNN"), even when every line runs the same plating
process. The shop wants **one work order per recipe** — different parts
that go through the same process should ride one traveller and one
physical batch, splitting into separate WOs **only when the process
actually differs**.
The blocker is the **Certificate of Conformance**: a `fp.job` carries a
single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders
exactly one part row. Collapsing four parts onto one WO would certify
only the first and silently ship the other three uncertified — the exact
"silent mis-attestation" the 2026-05-13 sticker spec was built to
prevent.
This spec resolves that by making the **certificate multi-part**: one
combined CoC per WO that lists every part in a table, each with its own
part #, spec, serial, and quantities. The grouping change and the
multi-part cert ship together because neither is safe alone.
## Audit findings (live entech, db=admin, read-only, 2026-06-03)
Pulled the real numbers before designing — they overturned the obvious
"group by `recipe_id`" approach.
| Order | Lines | WOs today | Distinct recipes | WOs after |
|-------|-------|-----------|------------------|-----------|
| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** |
| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** |
| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** |
| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** |
- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines
across those 4 orders collapse from **13 WOs → 4 WOs**.
- **Root cause:** every part gets its own *clone* of a base recipe,
renamed `<BASE> — <part#>` (the ` — <suffix>` is stamped by
`_clone_subtree` in `fp_part_composer_controller.py`). So each line
resolves to a *distinct* `fusion.plating.process.node` record →
grouping by `recipe_id` merges **nothing**.
- The clones are **byte-identical in structure** — 9 (or 11) descendant
nodes, same `node_type` + `kind_id.code` + name in the same order.
Verified across all 4 orders. So merging is **faithful**: every part
follows the identical steps.
- `process_type_id` is **empty** on all of them → not a usable signal.
- `cloned_from_id` exists as a field but is **empty on all 13** lines →
not usable for existing data without a backfill.
- **13 existing `fp.certificate` rows** → migration size.
**Conclusion:** the only signals that work on real data are *identical
step structure* and *shared base-name prefix*. We group by **identical
step structure** (truthful, naming-independent, no backfill).
## Locked decisions (from brainstorming, 2026-06-03)
| Q | Decision |
|---|----------|
| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. |
| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). |
| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. |
| Grouping signal? | **Identical step structure** (recipe structural signature). |
| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. |
## Goals / non-goals
**Goals**
- One WO per distinct plating process; same-process parts share one WO.
- A single combined CoC per WO listing each part's own identity + spec +
quantities.
- No silent loss of any part's certification when parts share a WO.
- Per-part masking/bake differences split the WO (never silently merge).
- Existing WOs and certs keep working unchanged; the 13 existing certs
render identically after migration.
**Non-goals**
- Re-grouping already-created WOs (only new confirmations regroup).
- Removing the per-part recipe-cloning mechanism (root-cause fix to the
Part Composer — separate, larger, riskier; out of scope).
- Per-part thickness rendering, per-part box stickers, per-part issue
gate → **Phase 2** (see below).
- Per-physical-box serial tracking (unchanged from prior specs).
## Architecture
### Phase 1 — compliance-safe MVP
#### Change 1 — Grouping by recipe structural signature
File: `fusion_plating_jobs/models/sale_order.py`, method
`_fp_auto_create_job` (the `groups` block around line 439-470).
Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a
**structural signature** key. New helpers on `sale.order`:
```python
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT name
(carries the per-part ' — <part#>' suffix) and all numeric targets
(thickness/temp/time/voltage) — those are per-part attestation
data captured on the cert, not a reason to split the batch.
Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express override flags that change physical processing
(masking on/off, bake setpoint/duration, etc.). Lines that differ
here must NOT merge even when the recipe structure matches, or the
shared WO would silently drop one part's masking/bake.
The exact field set is enumerated from sale.order.line's Express
Orders fields at implementation time (x_fc_masking_enabled + the
bake override fields); all reads are field-guarded.
"""
F = line._fields
bits = []
for fname in self._FP_EXPRESS_OVERRIDE_FIELDS:
if fname in F:
bits.append((fname, line[fname]))
return tuple(bits)
def _fp_line_group_key(self, line):
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
return ('recipe',
self._fp_recipe_signature(recipe),
self._fp_line_express_signature(line))
```
The grouping loop becomes:
```python
groups = {}
for line in plating_lines:
key = self._fp_line_group_key(line)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
Everything downstream of `groups` is unchanged: `ordered_keys` still
sorts by min line sequence, `n_groups` still drives single-vs-suffixed
WO naming (`WO-<parent>` vs `WO-<parent>-NN`), and the per-group job
create loop already sums qty, carries `sale_order_line_ids`, and copies
SO header fields.
**Representative recipe:** the WO's `recipe_id` is the first line's
recipe in the group. Because every recipe in the group is structurally
identical, step generation (`fp.job.action_confirm`
`_generate_steps_from_recipe`) produces the correct steps for all parts.
**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep
pointing at the first line's values (display + back-compat). The
per-part truth lives in `sale_order_line_ids` and the cert part-lines.
#### Change 2 — `fp.certificate.part` (new child model)
File: `fusion_plating_certificates/models/fp_certificate_part.py` (new).
```python
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one('sale.order.line') # traceability
part_catalog_id = fields.Many2one('fp.part.catalog')
part_number = fields.Char() # snapshot
part_name = fields.Char() # snapshot of catalog .name
description = fields.Char() # customer-facing description snapshot
serial = fields.Char() # comma-joined serial names snapshot
customer_spec_id = fields.Many2one('fusion.plating.customer.spec')
spec_reference = fields.Char() # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer()
nc_quantity = fields.Integer()
# Phase 2: thickness_reading_ids (inverse certificate_part_id)
```
On `fp.certificate`:
```python
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts')
```
Views: add an editable `part_line_ids` list to the certificate form
(so the issuer can review/adjust before issuing). ACL rows for
`fp.certificate.part` mirror `fp.certificate`'s groups (operator read +
manager write, matching the existing cert ACL).
#### Change 3 — `_fp_create_certificates` fills part-lines
File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716).
- **Requirement union** — `_resolve_required_cert_types` currently reads
the *first* part's `certificate_requirement`. Walk **all** plating
lines on the job; union each part's wanted set (part-level override
else partner inherit). Recipe suppression + CoC/thickness bundling are
unchanged (uniform — one recipe per WO).
- **Cert create** — still one cert per resulting type. Cert-level fields
(po_number, customer_job_no, process_description = base recipe name,
certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id)
unchanged. **Legacy singular fields** (part_number, spec_reference,
quantity_shipped, nc_quantity) keep being set from the **first** line
for back-compat.
- **Part-lines** — build one `fp.certificate.part` per plating line on
the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to
lines with a part):
```python
seq = 10
part_cmds = []
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False
part_cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description') else (sol.name or ''),
'serial': ', '.join(sol.x_fc_serial_ids.mapped('name'))
if 'x_fc_serial_ids' in sol._fields else '',
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
vals['part_line_ids'] = part_cmds
```
**Per-part quantities:** `quantity_shipped` defaults to the **line**
qty (naturally per-part). `nc_quantity` defaults to **0** — scrap /
visual rejects are tracked at job level only, not per part, so we do not
auto-split them; the issuer edits per-part NC at issue if needed. The
job-level NC total remains on the cert's legacy `nc_quantity` field.
**Idempotency:** the existing per-type idempotency guard is unchanged;
re-running `_fp_create_certificates` does not duplicate certs or lines.
#### Change 4 — CoC report renders the parts table as a loop
File: `fusion_plating_reports/report/report_coc.xml` (tbody at line
297-321).
```xml
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference"><br/><em t-esc="pl.spec_reference"/></t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<!-- Defensive fallback: legacy cert with no part-lines (should not
occur post-migration) renders the old single row. -->
<tr t-if="not doc.part_line_ids">
... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
</tr>
</tbody>
```
Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level
(uniform), kept cert-level. The Process column shows each part's own
customer-facing description + spec_reference (per 2026-05-28 policy).
`page-break-inside: avoid` stays on each `<tr>` (per CLAUDE.md) so a part
row never splits across a page.
#### Change 5 — Traveller lists all parts
File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`.
The Item Information block today shows one part (`job.part_catalog_id`).
Loop `job.sale_order_line_ids` (plating lines) so the operator sees every
part in the batch with its qty. The routing/process table is unchanged
(one shared recipe). Field reads stay defensively guarded.
#### Change 6 — Migration backfill
File: `fusion_plating_certificates/migrations/<new-version>/post-migrate.py`.
For each existing `fp.certificate` with no `part_line_ids`, create one
part-line from its current singular fields so old certs render
identically:
```python
for cert in env['fp.certificate'].search([]):
if cert.part_line_ids:
continue
pid = cert._fp_resolve_part_identity() # (number, name, serials)
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id
if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False),
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '',
'serial': pid[2] or '',
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
```
Idempotent (skips certs that already have part-lines). 13 certs → 13
single-part certs.
### Phase 2 — per-part refinement (separate plan)
- **Per-part thickness:** add `certificate_part_id` to
`fp.thickness.reading`; associate readings + page-2 Fischerscope PDF
merges per part; render a per-part thickness block under each part row;
extend the `action_issue` thickness gate to require data on each part
that needs thickness.
- **Per-part box stickers:** today's consolidated "Multiple Line Items"
sticker gains per-part detail / per-part labels.
- **Cert form polish:** richer part-line editing UX.
Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and
validated on entech.
## Files touched (Phase 1)
| # | File | Change |
|---|------|--------|
| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. |
| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. |
| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. |
| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. |
| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. |
| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. |
| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. |
| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. |
| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. |
| 10 | `fusion_plating_certificates/migrations/<ver>/post-migrate.py` | Backfill one part-line per existing cert. |
| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. |
## Migration
- New `fp.certificate.part` table created on install/upgrade.
- Post-migrate backfills the 13 existing certs (idempotent).
- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing:
return` guard means only **new** confirmations regroup.
- No re-grouping tool for open orders in Phase 1 (out of scope; can be a
one-off odoo-shell script later if the shop wants it).
## Testing
These modules require Enterprise deps and **cannot install on the local
Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so:
- **Static checks (local):** `pyflakes` on every changed `.py`; lxml
parse on changed XML; `node --check` not needed (no JS).
- **Unit (where installable):** the grouping helpers are pure functions
of a recipe/line — `_fp_recipe_signature` returns equal tuples for two
structurally-identical recipes and unequal for divergent ones;
`_fp_line_group_key` merges same-structure lines and splits on
differing express overrides.
- **Live verification (entech via odoo shell, read-only first):**
1. Re-run the audit signature on SO-30083/30079/30071/30092 →
confirm each collapses to 1 group.
2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process
lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different
processes → 2 WOs.
3. Confirm `_fp_create_certificates` produces one CoC with 4
part-lines; render the CoC PDF → 4 part rows, correct per-part
part#/serial/spec/qty.
4. Render an existing (migrated) single-part cert → identical to
before.
5. A line with masking ON + a line with masking OFF, same recipe →
**2** WOs (express-signature split).
## Edge cases & open questions
| Item | Decision |
|------|----------|
| No-recipe lines | Each its own WO (unchanged). |
| Same recipe structure, different express masking/bake | **Split** (express signature in the key). |
| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. |
| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) |
| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) |
| Job `part_catalog_id` when multi-part | First line (display/back-compat). |
| WO naming | `WO-<parent>` (1 group) / `WO-<parent>-NN` (N groups) — unchanged. |
| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. |
**Confirmed during review (2026-06-03):** the union-cert "list all
shipped parts even if one part opted out" behaviour, and the "per-part
NC defaults to 0, editable at issue" behaviour are both approved.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.22.2.0',
'version': '19.0.23.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -93,6 +93,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'data/fp_sequence_data.xml',
'data/fp_job_sequences.xml',
'data/fp_numbering_sequences.xml',
'data/fp_rack_load_sequence.xml',
'data/fp_process_category_data.xml',
# fp_menu.xml MUST load early — defines menu_fp_root, menu_fp_config,
# menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="seq_fp_rack_load" model="ir.sequence">
<field name="name">Rack Load</field>
<field name="code">fp.rack.load</field>
<field name="prefix">RACKLOAD/%(year)s/</field>
<field name="padding">4</field>
</record>
</odoo>

View File

@@ -55,3 +55,4 @@ from . import fp_landing
# imports the predicate chain + xmlid maps from the former).
from . import fp_role_constants
from . import fp_migration
from . import fp_rack_load

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Multi-rack splitting + WO grouping at Racking — Phase 1 core models.
# Spec: docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md
# Plan: docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md
#
# This file (core module) deliberately depends only on CORE fields. The
# racking-step-aware division ops, the fp.job rollups, movement, and the
# current_area_kind compute live in fusion_plating_jobs/models/fp_job_rack.py
# (that module owns fp.job.step.area_kind, fp.job.part_catalog_id, and
# _fp_is_racking_step).
from odoo import api, fields, models, _
class FpRackLoad(models.Model):
_name = 'fp.rack.load'
_description = 'Rack Load (parts on one physical rack)'
_inherit = ['mail.thread']
_order = 'id desc'
name = fields.Char(
string='Reference', required=True, copy=False, index=True,
default=lambda self: _('New'))
rack_id = fields.Many2one(
'fusion.plating.rack', string='Rack', tracking=True)
line_ids = fields.One2many(
'fp.rack.load.line', 'load_id', string='Work Orders')
qty_total = fields.Integer(
string='Total Parts', compute='_compute_qty_total', store=True)
current_step_id = fields.Many2one(
'fp.job.step', string='Current Step', tracking=True)
state = fields.Selection([
('loading', 'Loading'),
('loaded', 'Loaded'),
('running', 'Running'),
('unracked', 'Unracked'),
('cancelled', 'Cancelled'),
], string='State', default='loading', required=True, tracking=True)
tag_ids = fields.Many2many('fp.rack.tag', string='Tags')
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company)
@api.depends('line_ids.qty')
def _compute_qty_total(self):
for load in self:
load.qty_total = sum(load.line_ids.mapped('qty'))
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = (
self.env['ir.sequence'].next_by_code('fp.rack.load')
or _('New'))
return super().create(vals_list)
# ------------------------------------------------------------------
# Pure division math (no DB) — verifiable in isolation.
# ------------------------------------------------------------------
@api.model
def _fp_equal_split(self, total, n):
"""Split ``total`` parts across ``n`` racks as evenly as possible.
Remainder goes to the FIRST racks (spec D4): 100/3 -> [34, 33, 33].
Returns a list of n ints summing to total. n < 1 -> [].
"""
n = int(n)
if n < 1:
return []
base, rem = divmod(int(total), n)
return [base + 1 if i < rem else base for i in range(n)]
_qty_total_non_negative = models.Constraint(
'CHECK (qty_total >= 0)',
'Rack load total quantity cannot be negative.')
class FpRackLoadLine(models.Model):
_name = 'fp.rack.load.line'
_description = 'Rack Load Line (one work order on a rack)'
load_id = fields.Many2one(
'fp.rack.load', string='Rack Load', required=True, ondelete='cascade')
job_id = fields.Many2one('fp.job', string='Work Order', required=True)
qty = fields.Integer(string='Parts', required=True, default=0)
_qty_non_negative = models.Constraint(
'CHECK (qty >= 0)',
'Rack load line quantity cannot be negative.')

View File

@@ -40,6 +40,14 @@
<field name="privilege_id"
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
<field name="sequence">90</field>
<!-- 2026-06-02: office_user also grants "Contact Creation"
(base.group_partner_manager) so back-office staff + managers
can create contacts/companies. office_user is implied by every
fp role ABOVE Technician (Sales Rep, Shop Manager, Manager,
Quality Manager, Owner; Sales Manager via Sales Rep), so they
all inherit contact-creation. Pure Technicians do NOT imply
office_user, so they stay unable to create contacts. -->
<field name="implied_ids" eval="[(4, ref('base.group_partner_manager'))]"/>
<field name="comment">Marker group that controls visibility of
non-tablet app menus (Calendar, Sales, Inventory, etc.).
Implied by every fp role above Technician (Owner, Manager,

View File

@@ -97,3 +97,7 @@ access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager
access_fp_migration_preview_owner,fp.migration.preview.owner,model_fp_migration_preview,fusion_plating.group_fp_owner,1,1,1,1
access_fp_migration_preview_line_owner,fp.migration.preview.line.owner,model_fp_migration_preview_line,fusion_plating.group_fp_owner,1,1,1,1
access_ir_actions_actions_plating,ir.actions.actions.plating.read,base.model_ir_actions_actions,fusion_plating.group_fp_technician,1,0,0,0
access_fp_rack_load_tech,fp.rack.load.tech,model_fp_rack_load,fusion_plating.group_fp_technician,1,1,1,0
access_fp_rack_load_mgr,fp.rack.load.mgr,model_fp_rack_load,fusion_plating.group_fp_manager,1,1,1,1
access_fp_rack_load_line_tech,fp.rack.load.line.tech,model_fp_rack_load_line,fusion_plating.group_fp_technician,1,1,1,1
access_fp_rack_load_line_mgr,fp.rack.load.line.mgr,model_fp_rack_load_line,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
97 access_fp_migration_preview_owner fp.migration.preview.owner model_fp_migration_preview fusion_plating.group_fp_owner 1 1 1 1
98 access_fp_migration_preview_line_owner fp.migration.preview.line.owner model_fp_migration_preview_line fusion_plating.group_fp_owner 1 1 1 1
99 access_ir_actions_actions_plating ir.actions.actions.plating.read base.model_ir_actions_actions fusion_plating.group_fp_technician 1 0 0 0
100 access_fp_rack_load_tech fp.rack.load.tech model_fp_rack_load fusion_plating.group_fp_technician 1 1 1 0
101 access_fp_rack_load_mgr fp.rack.load.mgr model_fp_rack_load fusion_plating.group_fp_manager 1 1 1 1
102 access_fp_rack_load_line_tech fp.rack.load.line.tech model_fp_rack_load_line fusion_plating.group_fp_technician 1 1 1 1
103 access_fp_rack_load_line_mgr fp.rack.load.line.mgr model_fp_rack_load_line fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -11,3 +11,4 @@ from . import test_landing_resolver
from . import test_team_page
from . import test_sales_manager_gate
from . import test_migration_workflow
from . import test_rack_load

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Phase 1 — rack-load core model tests.
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestRackLoad(TransactionCase):
def test_equal_split_math(self):
"""Remainder goes to the first racks (spec D4)."""
Load = self.env['fp.rack.load']
self.assertEqual(Load._fp_equal_split(100, 1), [100])
self.assertEqual(Load._fp_equal_split(100, 2), [50, 50])
self.assertEqual(Load._fp_equal_split(100, 3), [34, 33, 33])
self.assertEqual(Load._fp_equal_split(100, 4), [25, 25, 25, 25])
self.assertEqual(Load._fp_equal_split(10, 3), [4, 3, 3])
self.assertEqual(Load._fp_equal_split(0, 3), [0, 0, 0])
self.assertEqual(Load._fp_equal_split(5, 0), [])
# sums always equal the total
self.assertEqual(sum(Load._fp_equal_split(97, 6)), 97)
def test_create_sequence_and_qty_total(self):
rack = self.env['fusion.plating.rack'].create({'name': 'RL-TEST-RACK'})
load = self.env['fp.rack.load'].create({'rack_id': rack.id})
self.assertTrue(load.name.startswith('RACKLOAD/'))
self.assertEqual(load.state, 'loading')
self.assertEqual(load.qty_total, 0)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.9.3.0',
'version': '19.0.10.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -5,6 +5,7 @@
from . import fp_thickness_reading
from . import fp_certificate
from . import fp_certificate_part
from . import res_config_settings
from . import res_partner
from . import fp_delivery

View File

@@ -87,6 +87,10 @@ class FpCertificate(models.Model):
thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
)
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the

View File

@@ -0,0 +1,38 @@
# -*- 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 fields, models
class FpCertificatePart(models.Model):
"""One row per part on a combined Certificate of Conformance.
A work order can cover several parts that share the same plating
process; the combined CoC lists each with its own identity, spec,
and quantities. Fields are snapshots taken at cert-creation time.
"""
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
_rec_name = 'part_number'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True,)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).',)
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec',)
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
# Per-part; the parent fp.certificate keeps cert-level legacy totals.
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')

View File

@@ -11,3 +11,6 @@ access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_t
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fp_thickness_upload_wiz_mgr fp.thickness.upload.wiz.manager model_fp_thickness_upload_wizard fusion_plating.group_fp_manager 1 1 1 1
12 access_fp_thickness_upload_wiz_line_sup fp.thickness.upload.wiz.line.supervisor model_fp_thickness_upload_wizard_line fusion_plating.group_fp_shop_manager_v2 1 1 1 1
13 access_fp_thickness_upload_wiz_line_mgr fp.thickness.upload.wiz.line.manager model_fp_thickness_upload_wizard_line fusion_plating.group_fp_manager 1 1 1 1
14 access_fp_certificate_part_operator fp.certificate.part.operator model_fp_certificate_part fusion_plating.group_fp_technician 1 1 0 0
15 access_fp_certificate_part_supervisor fp.certificate.part.supervisor model_fp_certificate_part fusion_plating.group_fp_shop_manager_v2 1 1 1 0
16 access_fp_certificate_part_manager fp.certificate.part.manager model_fp_certificate_part fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -152,6 +152,21 @@
invisible="trend_alert == 'ok'"/>
</group>
<notebook>
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
<page string="Thickness Readings" name="readings">
<field name="thickness_reading_ids">
<list editable="bottom">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.22.10.0',
'version': '19.0.22.13.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -79,6 +79,7 @@ class FpPartCatalog(models.Model):
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, ondelete='cascade',
tracking=True, domain="[('customer_rank', '>', 0)]",
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
)
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
revision = fields.Char(

View File

@@ -52,6 +52,7 @@ class FpQuoteConfigurator(models.Model):
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part (Catalog)',

View File

@@ -350,6 +350,14 @@ class SaleOrderLine(models.Model):
'steps run, with this text shown on the operator tablet under '
'fp.job.step.instructions.',
)
x_fc_masking_attachment_ids = fields.Many2many(
'ir.attachment',
'sale_order_line_masking_att_rel', 'line_id', 'attachment_id',
string='Masking Reference(s)',
help='Masking reference image(s)/PDF(s) captured at Express order '
'entry; applied to the job\'s masking step at job creation so '
'the operator sees what to mask.',
)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
copy=False,
@@ -840,6 +848,19 @@ class SaleOrderLine(models.Model):
})
if nodes:
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
elif self.x_fc_masking_attachment_ids:
# Masking ON + Express reference file(s) attached → surface them on
# the mask step so the operator sees what to mask. Lands on the
# second call (after steps exist), same as bake below.
mask_steps = job.step_ids.filtered(
lambda s: s.recipe_node_id.default_kind == 'mask'
)
if mask_steps:
mask_steps.sudo().write({
'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)],
})
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
% len(self.x_fc_masking_attachment_ids))
# 2. Bake — empty = opt out; non-empty = keep + write step.instructions
bake_text = (self.x_fc_bake_instructions or '').strip()

View File

@@ -91,6 +91,67 @@ export class FpExpressActionBtns extends Component {
);
if (action) await this.action.doAction(action);
}
// ---- Masking reference upload (2026-06-03) ----
// Visible only when masking is toggled ON for this line. Accepts MULTIPLE
// image/PDF files; each is attached to the line and (on order confirm)
// copied onto the job's masking step so the operator sees it in the
// workstation. Mirrors onUpload but loops over the file list.
get maskingEnabled() {
return !!this.props.record.data.masking_enabled;
}
get maskCount() {
const m = this.props.record.data.masking_attachment_ids;
return (m && m.count) || 0;
}
async onUploadMask(ev) {
ev.stopPropagation();
ev.preventDefault();
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = ".pdf,.png,.jpg,.jpeg,application/pdf,image/*";
input.onchange = async () => {
const files = Array.from(input.files || []);
if (!files.length) return;
if (!(await this._ensureSaved())) return;
let ok = 0;
for (const file of files) {
try {
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(",")[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
await this.orm.call(
this.props.record.resModel,
"action_upload_masking_ref",
[[this.props.record.resId]],
{
context: {
fp_masking_file: base64,
fp_masking_filename: file.name,
},
},
);
ok += 1;
} catch (e) {
this.notification.add(
`Masking upload failed for "${file.name}": ${e.message || e}`,
{ type: "danger" },
);
}
}
if (ok) {
this.notification.add(`${ok} masking reference(s) added.`, { type: "success" });
await this.props.record.load();
}
};
input.click();
}
}
export const fpExpressActionBtns = {

View File

@@ -441,6 +441,21 @@
cursor: not-allowed;
}
}
// MASK upload — amber so order-entry notices the "attach reference"
// affordance the moment masking is toggled on. Solid amber works on
// both the light and dark backend bundles (dark text on amber fill).
.o_fp_xpr_mask_btn {
color: #1f2937;
border-color: #d97706;
background: #fbbf24;
&:hover:not(:disabled) {
color: #1f2937;
border-color: #b45309;
background: #f59e0b;
}
}
}
// ============================================================

View File

@@ -16,6 +16,13 @@
title="Open the part record in a modal">
OPEN
</button>
<button t-if="maskingEnabled"
class="o_fp_xpr_action_stack_btn o_fp_xpr_mask_btn"
t-on-click="onUploadMask"
t-att-disabled="!hasPart"
title="Attach masking reference image(s)/PDF(s) — shown to the operator on the masking step">
MASK<t t-if="maskCount"> (<t t-esc="maskCount"/>)</t>
</button>
</div>
</t>

View File

@@ -279,6 +279,7 @@
<field name="customer_line_ref" string="Line Job #" placeholder="ABC" width="80px"/>
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010" width="100px"/>
<field name="masking_enabled" string="Mask" widget="boolean_toggle" width="55px"/>
<field name="masking_attachment_ids" column_invisible="1"/>
<!-- Bake pill — click to edit -->
<field name="bake_instructions"
string="Bake"

View File

@@ -573,6 +573,14 @@ class FpDirectOrderLine(models.Model):
help='Free-text bake instructions. Empty = bake steps are opted out. '
'Non-empty = bake step instructions on the operator tablet.',
)
masking_attachment_ids = fields.Many2many(
'ir.attachment',
'fp_direct_order_line_masking_att_rel', 'line_id', 'attachment_id',
string='Masking Reference(s)',
help='Image(s)/PDF(s) of what to mask. Carried to the SO line and '
'shown to the operator on the job\'s masking step. Only relevant '
'when Masking is enabled.',
)
# ---- Computes ----
@api.depends('quantity', 'unit_price')
@@ -766,6 +774,29 @@ class FpDirectOrderLine(models.Model):
'target': 'new',
}
def action_upload_masking_ref(self):
"""Attach a masking reference (image/PDF) to this line.
Called by the Express 'MASK REF' button — once per file (multi-select
loops in JS), via context keys fp_masking_file + fp_masking_filename.
Stored on the line's masking_attachment_ids; carried to the SO line
and the job's masking step at order confirm.
"""
self.ensure_one()
from odoo.exceptions import UserError
file_data = self.env.context.get('fp_masking_file')
filename = self.env.context.get('fp_masking_filename', 'masking-ref')
if not file_data:
raise UserError(_('No file data received.'))
att = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': file_data,
'res_model': 'fp.direct.order.line',
'res_id': self.id,
})
self.write({'masking_attachment_ids': [(4, att.id)]})
return True
def action_upload_drawing(self):
"""Attach a file (via context) to the line's part as a drawing.

View File

@@ -62,6 +62,11 @@ class FpDirectOrderWizard(models.Model):
partner_id = fields.Many2one(
'res.partner', string='Customer',
domain="[('customer_rank', '>', 0)]",
# 2026-06-02: default customer_rank=1 so a customer created inline
# (quick-create) from the Express Order form is marked as a customer
# and stays visible in this picker AND the Customers menu (both filter
# customer_rank>0). Without it new customers got rank 0 and vanished.
context={'default_customer_rank': 1},
tracking=True,
)
partner_invoice_id = fields.Many2one(
@@ -951,6 +956,7 @@ class FpDirectOrderWizard(models.Model):
'x_fc_customer_line_ref': line.customer_line_ref or False,
'x_fc_masking_enabled': line.masking_enabled,
'x_fc_bake_instructions': line.bake_instructions or False,
'x_fc_masking_attachment_ids': [(6, 0, line.masking_attachment_ids.ids)],
# Sub 9 — explicit tax override from the wizard line.
# When blank, Odoo will compute taxes from the product
# defaults at SO-line save time (the standard behaviour).

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.11.6.0',
'version': '19.0.12.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
spec = cert.customer_spec_id if 'customer_spec_id' in cert._fields else False
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'serial': pid[2] or '',
'customer_spec_id': spec.id if spec else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)

View File

@@ -7,7 +7,9 @@
from . import fp_job_workflow_state # Sub 14 — must load before fp_job (FK target)
from . import fp_job
from . import fp_job_sticker
from . import fp_job_step
from . import fp_job_masking
from . import fp_job_node_override
from . import fp_portal_job
from . import account_move
@@ -35,6 +37,10 @@ from . import report_fp_job_margin
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
# back on jobs without a cycle.)
from . import fp_job_consumption
# Multi-rack splitting at Racking (Phase 1) — jobs-side extension of
# fp.rack.load (core model in fusion_plating) + fp.job rollups.
from . import fp_job_rack
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
# hr.employee shop-roles inherit live in fusion_plating core so every
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a

View File

@@ -609,38 +609,47 @@ class FpJob(models.Model):
matches the defensive pattern used elsewhere in this file.
"""
self.ensure_one()
# ---- Step 1 — partner + part baseline ----
req = (
self.part_catalog_id
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
wanted = set()
# ---- Step 1 — partner + part baseline (union across all parts) ----
def _partner_inherit_set():
s = set()
p = self.partner_id
if p:
if p.x_fc_send_coc:
wanted.add('coc')
s.add('coc')
if p.x_fc_send_thickness_report:
wanted.add('thickness_report')
# Three aerospace/defence partner toggles. Field guards
# let this module load even if fusion_plating_certificates
# is at an older version that pre-dates the new fields.
if ('x_fc_send_nadcap_cert' in p._fields
and p.x_fc_send_nadcap_cert):
wanted.add('nadcap_cert')
if ('x_fc_send_mill_test' in p._fields
and p.x_fc_send_mill_test):
wanted.add('mill_test')
if ('x_fc_send_customer_specific' in p._fields
and p.x_fc_send_customer_specific):
wanted.add('customer_specific')
else:
wanted = {
'none': set(),
'coc': {'coc'},
s.add('thickness_report')
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
s.add('nadcap_cert')
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
s.add('mill_test')
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
s.add('customer_specific')
return s
def _explicit_set(req):
return {
'none': set(), 'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
if not parts:
parts = [False]
wanted = set()
inherit = None
for part in parts:
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
# ---- Step 2 — recipe suppression (suppress-only) ----
recipe = self.recipe_id
if recipe:
@@ -2655,6 +2664,58 @@ class FpJob(models.Model):
self.name, e,
)
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
# fp_customer_description() is a method (configurator), not a
# field — use hasattr, not a _fields check.
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc,
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running
@@ -2742,10 +2803,7 @@ class FpJob(models.Model):
# spec_reference is what action_issue blocks on.
# Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields:
ref = spec.code or ''
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
ref = self._fp_format_spec_ref(spec)
if ref:
vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields:
@@ -2781,6 +2839,10 @@ class FpJob(models.Model):
vals['contact_partner_id'] = contact.id
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
cert = Cert.create(vals)
self.message_post(body=Markup(_(
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer '

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Masking reference attachments — captured at Express order entry, surfaced
on the job's masking step (operator workstation) and rolled up to the job
form (office). Populated by sale.order.line._fp_apply_express_overrides_to_job.
"""
from odoo import api, fields, models
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
x_fc_masking_attachment_ids = fields.Many2many(
'ir.attachment',
'fp_job_step_masking_att_rel', 'step_id', 'attachment_id',
string='Masking Reference(s)',
help='Reference image(s)/PDF(s) of what to mask, attached at order '
'entry (Express) and shown to the operator on the masking step.',
)
class FpJob(models.Model):
_inherit = 'fp.job'
x_fc_masking_attachment_ids = fields.Many2many(
'ir.attachment',
compute='_compute_masking_attachment_ids',
string='Masking References',
help='All masking reference files across this job\'s masking steps.',
)
@api.depends('step_ids.x_fc_masking_attachment_ids')
def _compute_masking_attachment_ids(self):
for job in self:
atts = job.step_ids.mapped('x_fc_masking_attachment_ids')
job.x_fc_masking_attachment_ids = [(6, 0, atts.ids)]

View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Multi-rack splitting at Racking — Phase 1 jobs-module extension.
# Core models live in fusion_plating/models/fp_rack_load.py. This file owns
# everything that touches jobs-module fields (fp.job.step.area_kind,
# fp.job.part_catalog_id) and the racking-step detection (_fp_is_racking_step).
# Spec/plan: docs/superpowers/{specs,plans}/2026-06-03-racking-multi-rack-*.md
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpRackLoad(models.Model):
_inherit = 'fp.rack.load'
current_area_kind = fields.Char(
string='Current Area', compute='_compute_current_area_kind', store=True)
@api.depends('current_step_id.area_kind')
def _compute_current_area_kind(self):
for load in self:
load.current_area_kind = load.current_step_id.area_kind or False
# ------------------------------------------------------------------
# Racking-step resolution + the "total parts available to rack"
# ------------------------------------------------------------------
@api.model
def _fp_racking_step_for(self, job):
# Detect the racking step by area_kind == 'racking' (the corrected
# classification), NOT _fp_is_racking_step() — the latter keys off the
# step's kind, and de-racking steps are frequently mis-tagged
# kind='racking' in the data, which would wrongly match De-Racking.
return job.step_ids.filtered(lambda s: s.area_kind == 'racking')[:1]
@api.model
def _fp_racking_total(self, job):
step = self._fp_racking_step_for(job)
if step and step.qty_at_step:
return int(step.qty_at_step)
return int(job.qty or 0)
@api.model
def _fp_job_loads(self, job):
"""Active (not unracked/cancelled) loads carrying this job's parts."""
return self.search([
('line_ids.job_id', '=', job.id),
('state', 'in', ('loading', 'loaded', 'running')),
], order='id')
# ------------------------------------------------------------------
# Division API (operator's split + manual override)
# ------------------------------------------------------------------
@api.model
def _fp_split_job(self, job, n):
"""(Re)create n loads for `job`, equal split of the racking total.
Drops existing unmoved 'loading' loads first. Moved/assigned loads are
left alone (can't re-split parts that already advanced)."""
total = self._fp_racking_total(job)
self._fp_job_loads(job).filtered(
lambda l: l.state == 'loading' and not l.current_step_id).unlink()
qtys = self._fp_equal_split(total, max(int(n), 1))
loads = self.browse()
for q in qtys:
loads |= self.create({
'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})],
})
return loads
@api.model
def _fp_ensure_seeded(self, job):
"""Default state: one rack carrying all the parts."""
if not self._fp_job_loads(job):
self._fp_split_job(job, 1)
return self._fp_job_loads(job)
@api.model
def _fp_add_rack(self, job):
return self._fp_split_job(job, len(self._fp_job_loads(job)) + 1)
@api.model
def _fp_divide_equally(self, job):
return self._fp_split_job(job, max(len(self._fp_job_loads(job)), 1))
def _fp_set_qty(self, qty):
"""Manual override of a single load's quantity (must not exceed the
job's available parts across all its loads)."""
self.ensure_one()
line = self.line_ids[:1]
if not line:
raise UserError(_('This rack has no work order line.'))
job = line.job_id
total = self._fp_racking_total(job)
other = sum((self._fp_job_loads(job) - self).mapped('qty_total'))
if other + int(qty) > total:
raise UserError(
_('Assigned %(a)s exceeds the %(t)s parts available to rack.')
% {'a': other + int(qty), 't': total})
line.qty = int(qty)
def _fp_remove_rack(self):
self.ensure_one()
if self.current_step_id:
raise UserError(_('Cannot remove a rack that has already moved.'))
self.unlink()
# ------------------------------------------------------------------
# Independent movement + de-racking
# ------------------------------------------------------------------
def _fp_advance_to(self, to_step):
"""Move these rack-loads to `to_step`: one move row per line (per WO),
carrying the rack + line qty, then update position/state."""
Move = self.env['fp.job.step.move']
for load in self:
from_step = load.current_step_id
for line in load.line_ids:
Move.create({
'job_id': line.job_id.id,
'from_step_id': from_step.id if from_step else False,
'to_step_id': to_step.id,
'qty_moved': line.qty,
'rack_id': load.rack_id.id if load.rack_id else False,
'transfer_type': 'step',
'moved_by_user_id': self.env.user.id,
})
load.current_step_id = to_step
load.state = 'running'
def _fp_unrack(self):
"""De-Racking: free the physical rack; each line's parts continue in
its own job's flow (the per-line moves already attributed qty)."""
for load in self:
load.state = 'unracked'
if load.rack_id:
load.rack_id.racking_state = 'empty'
class FpRackLoadLine(models.Model):
_inherit = 'fp.rack.load.line'
part_catalog_id = fields.Many2one(
related='job_id.part_catalog_id', store=True, string='Part')
class FpJob(models.Model):
_inherit = 'fp.job'
rack_load_line_ids = fields.One2many(
'fp.rack.load.line', 'job_id', string='Rack Loads')
qty_racked = fields.Integer(
string='Parts Racked', compute='_compute_qty_racked')
qty_unracked = fields.Integer(
string='Parts Unassigned', compute='_compute_qty_racked')
@api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
def _compute_qty_racked(self):
Load = self.env['fp.rack.load']
for job in self:
active = job.rack_load_line_ids.filtered(
lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
job.qty_racked = sum(active.mapped('qty'))
total = Load._fp_racking_total(job)
job.qty_unracked = max(total - job.qty_racked, 0)

View File

@@ -157,33 +157,122 @@ class FpJobStep(models.Model):
@api.depends(
'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind',
'name',
'recipe_node_id.kind_id.code',
'sequence',
'job_id.step_ids.sequence',
'job_id.step_ids.name',
'job_id.step_ids.work_centre_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.code',
)
def _compute_area_kind(self):
"""Resolve the plant-view column this step belongs in.
Priority chain:
1. work_centre.area_kind (explicit operator setup wins)
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
3. catch-all 'plating' (data integrity issue if we land here)
Priority chain (non-gating steps):
1. step-NAME override for unambiguous de-rack / de-mask / bake
steps (2026-06-03) — their recipe kind and/or work-centre is
frequently wrong (tagged 'racking'/'mask', a shared station, or
left blank), scattering cards across the Racking / Masking /
Plating columns. The operator-facing NAME is unambiguous, so it
wins OUTRIGHT — even over an explicit work-centre. Bake/oven
steps that merely mention "de-rack" stay in Baking. See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
2. work_centre.area_kind (explicit operator setup)
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
4. catch-all 'plating' (data integrity issue if we land here)
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
now self-declares its area_kind, so the kind taxonomy IS the
source of truth. See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
Gating/marker steps (kind `code == 'gating'` — the "Ready for X"
steps) have NO physical location; the taxonomy maps them to
'receiving', which made a mid-recipe gate snap the job's card back
to the first column (Racking -> "Ready for processing" jumped to
Receiving, so the job looked like it vanished — 2026-06-02). A
gating step FALLS FORWARD to the next non-gating step's column
(it's "ready for [that stage]"), keeping the card moving
left->right. If nothing real follows, it falls back to the last
real stage.
"""
for step in self:
# 1. Explicit work_centre wins
if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind
continue
# 2. Kind taxonomy
node = step.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue
# 3. Catch-all — only reached for orphaned steps (no
# work_centre AND no recipe_node).
step.area_kind = 'plating'
step.area_kind = step._fp_resolve_area_kind()
def _fp_raw_area_kind(self):
"""Area from this step's OWN name / work_centre / kind only — no
look-ahead and no dependence on the computed `area_kind` field (so
the gating fall-forward below can't recurse).
Name override (de-rack/de-mask -> De-Racking, bake/oven -> Baking)
wins OUTRIGHT: the authored kind / work-centre is frequently
wrong/blank for these. See _fp_area_from_step_name."""
self.ensure_one()
name_area = self._fp_area_from_step_name(self.name)
if name_area:
return name_area
if self.work_centre_id and self.work_centre_id.area_kind:
return self.work_centre_id.area_kind
node = self.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
return node.kind_id.area_kind
return 'plating'
def _fp_is_gating_step(self):
"""True for a 'Ready for X' marker step (no physical location).
Detected via the STABLE kind code, never the display name."""
self.ensure_one()
node = self.recipe_node_id
return bool(node and node.kind_id and node.kind_id.code == 'gating')
def _fp_resolve_area_kind(self):
"""Column for this step: its own raw area, EXCEPT a gating marker
falls forward to the next non-gating step's column."""
self.ensure_one()
if not self._fp_is_gating_step():
return self._fp_raw_area_kind()
siblings = self.job_id.step_ids
later = siblings.filtered(
lambda s: s.sequence > self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if later:
return later[0]._fp_raw_area_kind()
earlier = siblings.filtered(
lambda s: s.sequence < self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if earlier:
return earlier[-1]._fp_raw_area_kind()
return self._fp_raw_area_kind()
@staticmethod
def _fp_area_from_step_name(name):
"""Unambiguous step-name -> area_kind override (area or None).
The recipe kind is frequently wrong/blank for these step types, so
the operator-facing NAME is the more reliable signal and wins over
kind/work-centre in _compute_area_kind:
- bake / oven -> 'baking' (checked FIRST so "Oven bake (Post
de-rack)" counts as a bake, not a de-rack). Excludes
inspection-of-bake names ("post-bake inspection/QC/test") and
part-number / generic references ("General Processing -
BAKE-K464034") so only real bake operations move.
- de-rack / de-mask -> 'de_racking'.
Everything else returns None so the normal work-centre / kind /
fallback chain applies.
"""
x = (name or '').strip().lower()
if not x:
return None
# bake / oven first — a "post de-rack" oven bake IS a bake
if 'oven' in x or 'bake' in x:
if any(w in x for w in (
'processing', 'inspect', 'check', 'qc',
'test', 'verif', 'review')):
return None
return 'baking'
# de-rack / de-mask
flat = x.replace('-', '').replace('_', '').replace(' ', '')
if 'derack' in flat or 'demask' in flat:
return 'de_racking'
return None
last_activity_at = fields.Datetime(
string='Last Activity',
@@ -698,19 +787,29 @@ class FpJobStep(models.Model):
operator to finish manually (the board will show it "running, 0
here", which reads as "finish me").
Only fires for steps that had REAL incoming parts — never an
untouched first-step seed. Returns True if the step finished.
Fires for any step that actually moved parts OUT and drained to
zero — INCLUDING the first/seeded stage (its qty comes from the
qty_at_step seed, not a real incoming move). Returns True if the
step finished.
"""
self.ensure_one()
if self.state != 'in_progress':
return False
if not self._fp_has_real_incoming():
return False
# qty_at_step is a non-stored compute off the move rows — force a
# re-read so we see the just-committed outgoing move.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step != 0:
return False
# Guard: only auto-finish a step that genuinely moved parts OUT (a
# real outgoing move, excluding self-loop measurement moves). The
# earlier guard checked _fp_has_real_incoming() — the WRONG
# direction: the first/seeded stage (e.g. Racking) is fed by the
# qty_at_step seed, not an incoming move, so it never auto-finished
# when all its parts were sent forward. Checking for a real
# OUTGOING move covers the seeded first stage correctly.
if not self.move_ids.filtered(
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 0):
return False
try:
self.button_finish()
return True

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Display helpers for the redesigned job stickers (Internal = Layout A,
one per job; External = Layout B, one per box).
Keeps the QWeb templates thin: all field resolution, the customer
short-code (shop-floor "secrecy cover"), em-dash/smart-quote cleanup for
the entech wkhtmltopdf font, and the length-tiered notes font size live
here in Python.
"""
from odoo import models
def _clean(text):
"""Strip the glyphs entech's wkhtmltopdf font mojibakes."""
if not text:
return ''
t = str(text)
for a, b in ((u'', '-'), (u'', '-'), (u'', "'"),
(u'', "'"), (u'', '"'), (u'', '"'),
(u'', '...')):
t = t.replace(a, b)
return t.strip()
class FpJob(models.Model):
_inherit = 'fp.job'
def _fp_sticker_shortcode(self, partner):
"""ABC Manufacturing Inc -> 'ABC-MANU'. First 3 of word[0] + first 4
of word[1] (alnum-only), uppercase. Single word -> first 3."""
name = (partner.name or '') if partner else ''
words = [''.join(c for c in w if c.isalnum()) for w in name.split()]
words = [w for w in words if w]
if len(words) >= 2:
return (words[0][:3] + '-' + words[1][:4]).upper()
if words:
return words[0][:3].upper()
return name or '-'
def _fp_note_pt(self, text):
"""Length-tiered notes font (pt) so long instructions stay on one
label. Mirrors the approved mockups."""
n = len(text or '')
if n <= 180:
return 11.0
if n <= 320:
return 10.0
if n <= 520:
return 9.0
return 8.5
def _fp_sticker_data(self):
"""Resolved display values for the job sticker (both variants)."""
self.ensure_one()
job = self
line = job.sale_order_line_ids[:1] if 'sale_order_line_ids' in job._fields \
else job.env['sale.order.line']
part = (('part_catalog_id' in job._fields and job.part_catalog_id)
or (line and 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id)
or False)
so = job.sale_order_id
rev = ''
if part and getattr(part, 'revision', False):
rev = (part.revision or '').strip()
if rev.lower().startswith('rev '):
rev = rev[4:].strip()
due = job.date_deadline or (so and so.commitment_date) or False
due_s = due.strftime('%b %d %Y') if due else ''
thk = ''
if line and 'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range:
thk = _clean(line.x_fc_thickness_range)
q = job.qty or 0
qty = int(q) if float(q).is_integer() else q
return {
'wo': job.name or '',
'part': ((part.part_number if part and getattr(part, 'part_number', False)
else (part.name if part else '')) or ''),
'rev': rev,
'customer': self._fp_sticker_shortcode(job.partner_id),
'customer_full': job.partner_id.name or '',
'po': (so and getattr(so, 'x_fc_po_number', False)) or '',
'qty': qty,
'due': due_s,
'thk': thk,
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
'customer_notes': _clean(line.name) if line else '',
}
def _fp_sticker_boxes(self):
"""The job's tracked boxes (External sticker prints one label each).
Empty recordset when none yet — the template falls back to 1/1."""
self.ensure_one()
if self.sale_order_id and 'fp.box' in self.env:
return self.env['fp.box'].sudo().search(
[('sale_order_id', '=', self.sale_order_id.id)], order='box_number')
return self.env['fp.box'] if 'fp.box' in self.env else self.browse()

View File

@@ -395,6 +395,66 @@ class SaleOrder(models.Model):
return part.recipe_id
return Node
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit).
When the Express fields are absent on a line's module, masking
defaults to True and bake to False, so a non-Express line groups
as masking-on / no-bake.
"""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line, sig_cache=None):
"""WO grouping key. Lines with the same key ride one work order.
`sig_cache` (optional) memoises recipe-id -> signature so a
multi-line SO doesn't re-search the same recipe tree per line.
"""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
if sig_cache is None:
sig = self._fp_recipe_signature(recipe)
else:
if recipe.id not in sig_cache:
sig_cache[recipe.id] = self._fp_recipe_signature(recipe)
sig = sig_cache[recipe.id]
if not sig:
# A recipe with no step nodes has no structure to share —
# don't let empty-tree shells silently merge into one WO.
return ('no_recipe', line.id)
return ('recipe', sig, self._fp_line_express_signature(line))
def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines.
@@ -436,37 +496,14 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker —
# silent mis-attestation. No-recipe lines still get their own
# group each.
# Group by recipe structural signature (+ per-line masking/bake
# toggles). Lines whose recipes have identical steps collapse onto
# one WO; no-recipe lines stay separate. See spec
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
groups = {}
unrecipe_idx = 0
_sig_cache = {}
for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_key = (
'x_fc_thickness_range' in line._fields
and (line.x_fc_thickness_range or '').strip()
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
key = self._fp_line_group_key(line, sig_cache=_sig_cache)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO

View File

@@ -3,12 +3,17 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Native fp.job sticker — reuses the canonical box-sticker design from
fusion_plating_reports.report_fp_wo_sticker_inner. The visual layout
(logo + WO# stack on the left, big QR on the right, 7-row body table
underneath, all wrapped in a 2px border) is the one shop staff have
been printing since the mrp.production days; we just feed it from
fp.job fields here instead of mrp.production.
Redesigned job stickers (thermal label, 6x4 in / 152x102 mm):
* Internal Job Sticker — Layout A (stacked, full-width notes),
ONE label per job. Shop copy: x_fc_internal_description notes,
job QR (/fp/job/<id>).
* External Job Sticker — Layout B (left rail + tall notes),
ONE label per fp.box. Customer copy: factory logo, BOX n/N,
per-box QR (/fp/box/<id>), customer-facing description notes.
Dynamic: MASK badge when masking enabled, BAKE block when bake
instructions present, length-tiered notes font. Field resolution +
short-code + cleanup live in fp.job._fp_sticker_data() (Python).
-->
<odoo>
@@ -25,8 +30,12 @@
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/>
<!-- dpi=300 calibrated — see CLAUDE.md rule 14, 600 broke layout. -->
<field name="dpi">300</field>
<!-- dpi=96 (NOT 300): this label is laid out in mm (matches the
approved Chrome-rendered mockups). At dpi=300 wkhtmltopdf shrinks
mm content to ~96/300 of true size (CLAUDE.md rule 14). 96 maps
mm 1:1 so it fills the page; QR/logo stay crisp (embedded at their
own resolution, text is vector). Legacy px-based stickers keep 300. -->
<field name="dpi">96</field>
</record>
<record id="action_report_fp_job_sticker" model="ir.actions.report">
@@ -41,49 +50,6 @@
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<template id="report_fp_job_sticker_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<!-- Defaults block initialises every var the inner
reads (so `_so or ...` doesn't NameError). We
then override the ones we have data for. -->
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<!-- Multi-line trigger: parent SO has 2+ part-bearing lines.
Even though this job is for a single specific part (jobs
are grouped by recipe+part+coating+thickness+SN), the
consolidated PO sticker is the requested behaviour. -->
<t t-set="_so_part_lines" t-value="job.sale_order_id
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
or job.env['sale.order.line']"/>
<t t-set="_multi_line" t-value="len(_so_part_lines) &gt;= 2"/>
<!-- Pre-resolve the variables the shared inner template
expects, sourcing them from fp.job's native fields. -->
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress
the muted "(WH/MO/...)" suffix on the PO row. -->
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</template>
<!-- Internal Job sticker — same fields as External, but the Notes
column reads x_fc_internal_description from the first linked
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
share a job, so first-line is representative). -->
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
<field name="name">Internal Job Sticker</field>
<field name="model">fp.job</field>
@@ -96,36 +62,216 @@
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<!-- ============================ Shared CSS ============================ -->
<template id="fp_job_sticker_styles">
<style>
@page { size: 152mm 102mm; margin: 0; }
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
.label-page { width: 152mm; height: 102mm; position: relative; overflow: hidden; page-break-after: always; }
.label { position: absolute; top: 2mm; left: 2mm; right: 2mm; bottom: 2mm; border: 0.9mm solid #000; overflow: hidden; }
.fpt { border-collapse: collapse; width: 100%; }
.fpt td { vertical-align: middle; }
.lbl { font-size: 7.5pt; font-weight: bold; letter-spacing: 0.4pt; text-transform: uppercase; display: block; }
.band { background: #000; color: #fff; }
.pad { padding: 1mm 2.5mm; }
.vrule { border-right: 0.5mm solid #000; }
.rule { border-bottom: 0.6mm solid #000; }
.badge { display: inline-block; background: #000; color: #fff; font-size: 10pt; font-weight: 900; padding: 0.6mm 2.2mm; margin-left: 1.5mm; }
.tag { display: inline-block; background: transparent; color: #fff; border: 0.5mm solid #fff; font-size: 8pt; font-weight: bold; padding: 0.4mm 2mm; }
.inshead { font-size: 8.5pt; font-weight: 900; letter-spacing: 0.6pt; background: #000; color: #fff; display: inline-block; padding: 0.5mm 2.5mm; }
.instext { font-weight: bold; }
/* Layout B rail + main */
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
.r-logo { height: 11mm; line-height: 11mm; text-align: center; }
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
.r-wo { height: 14mm; background: #000; color: #fff; padding: 0; }
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
.r-qrflags { height: 36mm; text-align: center; }
.qftbl { border-collapse: collapse; width: 100%; height: 100%; }
.qftbl td { vertical-align: middle; text-align: center; }
.qfqr { width: 66%; }
<!-- QR quiet-zone crop: the barcode bakes a ~12% white border
around the pattern. Render the image oversized in an
overflow:hidden wrapper, offset so the wrapper clips ~10% off
each edge (under the quiet zone, so no modules are lost) — the
black pattern then fills the box. White label cell around the
wrapper still provides the scan margin. CLAUDE.md rule 14. -->
.qfwrap-qr { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
.qftags { width: 34%; border-left: 0.5mm solid #000; }
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
.qffull { line-height: 36mm; }
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; }
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; }
/* Internal (Layout A) header QR — same ~10% quiet-zone crop, bigger box. */
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; }
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; }
.r-fld { padding: 1mm 2.2mm; }
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
</style>
</template>
<!-- ===================== Internal body — Layout A ===================== -->
<template id="fp_job_internal_body">
<div class="label-page"><div class="label">
<table class="fpt">
<tr style="height:22mm" class="rule">
<td class="band pad">
<span style="float:right"><span class="tag">INTERNAL</span></span>
<span class="lbl" style="color:#fff">Work Order</span>
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
</td>
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;vertical-align:middle;padding:1mm">
<span class="qfwrap-int"><img t-att-src="_qr"/></span>
</td>
</tr>
<tr style="height:12mm" class="rule">
<td class="pad" colspan="2">
<span class="lbl">Part#</span>
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
<span style="float:right">
<t t-if="d['mask']"><span class="badge">MASK</span></t>
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
</span>
</td>
</tr>
<tr style="height:10mm" class="rule">
<td style="padding:0" colspan="2"><table class="fpt"><tr>
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></td>
</tr>
<t t-if="d['bake']">
<tr style="height:13mm" class="rule">
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
<span class="inshead">BAKE</span>
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
</td>
</tr>
</t>
<tr>
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
<span class="inshead">NOTES</span>
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
</td>
</tr>
</table>
</div></div>
</template>
<!-- ===================== External body — Layout B ===================== -->
<template id="fp_job_external_body">
<div class="label-page"><div class="label">
<div class="rail">
<div class="r-logo rule">
<img t-if="_logo" t-att-src="image_data_uri(_logo)"/>
<span t-if="not _logo" style="font-size:11pt;font-weight:900"><t t-esc="d['customer_full'][:18]"/></span>
</div>
<div class="r-wo">
<table class="wobtbl"><tr>
<td class="vrule" style="width:52%;border-right-color:#fff">
<span class="lbl" style="color:#fff">Work Order</span>
<span class="bignum"><t t-esc="d['wo'].split('-')[-1].split('/')[-1]"/></span>
</td>
<td>
<span class="lbl" style="color:#fff">Box</span>
<span class="bignum"><t t-esc="_box_num"/> / <t t-esc="_box_cnt"/></span>
</td>
</tr></table>
</div>
<div class="r-qrflags rule">
<t t-if="d['mask'] or d['bake']">
<table class="qftbl"><tr>
<td class="qfqr"><span class="qfwrap-qr"><img t-att-src="_qr"/></span></td>
<td class="qftags">
<t t-if="d['mask']"><span class="badge">MASK</span></t>
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
</td>
</tr></table>
</t>
<t t-else="">
<table class="qftbl"><tr><td><span class="qfwrap-full"><img t-att-src="_qr"/></span></td></tr></table>
</t>
</div>
<div class="r-fld rule">
<span class="lbl">Part#</span>
<span style="font-size:11.5pt;font-weight:900"><t t-esc="d['part'] or '-'"/></span>
<t t-if="d['rev']"><span style="font-size:8.5pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
</div>
<div class="r-fld rule"><span class="lbl">Customer</span><span style="font-size:11pt;font-weight:900"><t t-esc="d['customer']"/></span></div>
<div class="rule" style="height:8.5mm"><table class="gtbl"><tr>
<td class="vrule" style="width:55%"><span class="lbl">PO#</span><span style="font-size:9.5pt;font-weight:bold;display:block"><t t-esc="d['po'] or '-'"/></span></td>
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
</tr></table></div>
<div style="height:8.5mm"><table class="gtbl"><tr>
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></div>
</div>
<div class="main">
<t t-if="d['bake']">
<div class="m-bake rule"><span class="inshead">BAKE</span>
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
</div>
</t>
<div class="m-notes"><span class="inshead">NOTES</span>
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
</div>
</div>
</div></div>
</template>
<!-- ===================== Internal outer (per job) ===================== -->
<template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container">
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
<t t-foreach="docs" t-as="job">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_so_part_lines" t-value="job.sale_order_id
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
or job.env['sale.order.line']"/>
<t t-set="_multi_line" t-value="len(_so_part_lines) &gt;= 2"/>
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description from
the first linked SO line. Multi-line PO blanks it
since each line has its own description. -->
<t t-set="_notes_content" t-value="'-' if _multi_line else
((job.sale_order_line_ids[:1]
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-')"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
<t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['internal_notes']"/>
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
<t t-call="fusion_plating_jobs.fp_job_internal_body"/>
</t>
</t>
</template>
<!-- ===================== External outer (per box) ===================== -->
<template id="report_fp_job_sticker_template">
<t t-call="web.html_container">
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
<t t-foreach="docs" t-as="job">
<t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['customer_notes']"/>
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
<t t-if="boxes">
<t t-foreach="boxes" t-as="box">
<t t-set="_box_num" t-value="box.box_number"/>
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t>
<t t-else="">
<t t-set="_box_num" t-value="1"/>
<t t-set="_box_cnt" t-value="1"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t>
</t>
</template>

View File

@@ -142,6 +142,16 @@
<span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/>
<strong>S/N:</strong>
<t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t>
<!-- Multi-part batch: list every distinct part on this WO
(the labeled block above details the primary part). -->
<t t-set="trav_lines" t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else False"/>
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id') if trav_lines else False"/>
<t t-if="trav_parts and len(trav_parts) &gt; 1">
<br/><strong>Batch parts:</strong>
<t t-foreach="trav_parts" t-as="tp">
<div style="font-size: 7pt;"><span t-esc="tp.part_number or '—'"/><t t-if="'revision' in tp._fields and tp.revision"> Rev <span t-esc="tp.revision"/></t></div>
</t>
</t>
</td>
<td>
<strong>

View File

@@ -10,3 +10,5 @@ from . import test_autopause_cron
from . import test_post_shop_states
from . import test_recipe_cert_suppression
from . import test_order_ship_state
from . import test_combined_cert_creation
from . import test_wo_recipe_grouping

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
def test_part_lines_fall_back_to_so_order_line(self):
# Job without an explicit sale_order_line_ids M2M still builds
# one part-line per plating line via the SO order_line fallback.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1)
self.assertEqual(len(cert.part_line_ids), 2,
'falls back to SO order_line when no M2M lines set')

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
# kind_id is required on process.node; reuse any seeded kind so
# node creation doesn't depend on the default lookup resolving.
self.kind = self.env['fp.step.kind'].search([], limit=1)
def _node_vals(self, name, node_type):
v = {'name': name, 'node_type': node_type}
if self.kind:
v['kind_id'] = self.kind.id
return v
def _recipe(self, name, step_names):
root = self.Node.create(self._node_vals(name, 'recipe'))
seq = 10
for sn in step_names:
v = self._node_vals(sn, 'step')
v.update({'parent_id': root.id, 'sequence': seq})
self.Node.create(v)
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')

View File

@@ -302,6 +302,17 @@
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//page[@name='costs']" position="before">
<page string="Masking Refs" name="masking_refs"
invisible="not x_fc_masking_attachment_ids">
<div class="text-muted mb-2">
Masking reference image(s)/PDF(s) attached at order entry (Express).
The operator sees these on the masking step in the workstation.
</div>
<field name="x_fc_masking_attachment_ids" widget="many2many_binary"
readonly="1" nolabel="1"/>
</page>
</xpath>
<xpath expr="//page[@name='costs']" position="before">
<page string="Notes" name="notes">
<group>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Add a plating-signature pad to the user preferences dialog.
Anchors on the existing HTML 'signature' field (email signature)
and adds our binary image-signature right after it. The
widget="signature" gives finger / mouse drawing + image upload.
-->
<record id="view_users_preferences_form_fp_signature" model="ir.ui.view">
<field name="name">res.users.preferences.form.fp.signature</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='signature']" position="after">
<field name="x_fc_signature_image"
widget="signature"
string="Plating Signature"
options="{'full_name': 'name'}"/>
</xpath>
</field>
</record>
<!-- Same field on the full user form (Settings > Users) so admins
can review or seed signatures for operators who aren't tech-
savvy enough to do it themselves. -->
<record id="view_users_form_fp_signature" model="ir.ui.view">
<field name="name">res.users.form.fp.signature</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='signature']" position="after">
<field name="x_fc_signature_image"
widget="signature"
string="Plating Signature"
options="{'full_name': 'name'}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -5,3 +5,4 @@
from . import models
from . import wizards
from . import controllers

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.28.3',
'version': '19.0.3.29.1',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """
@@ -44,6 +44,7 @@ Provides:
'views/fp_racking_inspection_views.xml',
'views/sale_order_views.xml',
'views/fp_receiving_menu.xml',
'views/fp_box_views.xml',
'views/fusion_shipment_inherit_views.xml',
'wizards/fp_label_manual_wizard_views.xml',
'wizards/fp_label_generate_wizard_views.xml',

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
from . import fp_box_controller

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Box scan endpoint. The per-box QR on the External Job Sticker encodes
``/fp/box/<id>``; scanning it (logged-in operator on the tablet) lands on
the box's backend form where they can advance its status."""
from odoo import http
from odoo.http import request
class FpBoxScan(http.Controller):
@http.route(['/fp/box/<int:box_id>'], type='http', auth='user', website=False)
def fp_box_scan(self, box_id, **kw):
box = request.env['fp.box'].sudo().browse(box_id).exists()
if not box:
return request.not_found()
# Land on the box form in the web client (operator advances status there).
return request.redirect('/web#id=%s&model=fp.box&view_type=form' % box.id)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# Backfill fp.box records for receivings that were counted BEFORE box-level
# tracking shipped. Idempotent: skips any receiving that already has boxes.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
from odoo import api, SUPERUSER_ID
env = api.Environment(cr, SUPERUSER_ID, {})
recs = env['fp.receiving'].search([('box_count_in', '>', 0)])
done = 0
for rec in recs:
if not rec.box_ids:
rec._fp_sync_boxes()
done += 1
_logger.info('fp.box backfill: created boxes for %s receiving(s)', done)

View File

@@ -6,6 +6,7 @@
from . import fp_receiving_damage
from . import fp_receiving_line
from . import fp_outbound_package
from . import fp_box
from . import fp_receiving
from . import fp_racking_inspection
from . import sale_order

View File

@@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Per-box registry for box-level tracking.
One `fp.box` per physical box received against a `fp.receiving`. Auto-created
when the receiver enters `box_count_in` and marks the receiving Counted
(see `fp.receiving._fp_sync_boxes`). Each box carries a sequence number
(n of N), a status that advances through the shop, and a scannable identity
(`/fp/box/<id>`) printed on the External Job Sticker — one label per box.
Box-level tracking (not box CONTENTS): we track WHICH box and WHERE it is,
not the per-box part breakdown. The same boxes go back to the customer
(Sub 8), so reconciliation flags any box that never reaches `shipped`.
"""
from odoo import api, fields, models, _
STATE_ORDER = ['received', 'racked', 'in_process', 'packed', 'shipped']
class FpBox(models.Model):
_name = 'fp.box'
_description = 'Fusion Plating — Tracked Box'
_inherit = ['mail.thread']
_order = 'receiving_id, box_number'
name = fields.Char(string='Box', compute='_compute_name', store=True)
box_number = fields.Integer(string='Box #', required=True, default=1, tracking=True)
box_count = fields.Integer(string='Of', tracking=True,
help='Total boxes in this receiving (N in "n of N").')
receiving_id = fields.Many2one('fp.receiving', string='Receiving', required=True,
ondelete='cascade', index=True)
sale_order_id = fields.Many2one('sale.order', string='Sale Order',
related='receiving_id.sale_order_id', store=True, index=True)
partner_id = fields.Many2one('res.partner', string='Customer',
related='receiving_id.partner_id', store=True)
job_id = fields.Many2one('fp.job', string='Work Order', index=True,
help='Resolved job for this box (single-job SO). '
'The sticker resolves boxes via the SO when blank.')
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env.company, index=True)
state = fields.Selection([
('received', 'Received'),
('racked', 'Racked'),
('in_process', 'In Process'),
('packed', 'Packed'),
('shipped', 'Shipped'),
('lost', 'Lost'),
('cancelled', 'Cancelled'),
], string='Status', default='received', required=True, tracking=True, index=True)
location_note = fields.Char(string='Location / Note', tracking=True,
help='Free text — where is this box now (rack, bay, shelf).')
scan_url = fields.Char(string='Scan URL', compute='_compute_scan_url')
_box_uniq = models.Constraint(
'UNIQUE(receiving_id, box_number)',
'Box number must be unique within a receiving.')
# ------------------------------------------------------------------ computes
@api.depends('box_number', 'box_count', 'receiving_id.name', 'sale_order_id.name')
def _compute_name(self):
for rec in self:
base = rec.receiving_id.name or (rec.sale_order_id.name if rec.sale_order_id else '') or 'BOX'
rec.name = '%s · Box %d/%d' % (base, rec.box_number or 1, rec.box_count or 1)
def _compute_scan_url(self):
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
for rec in self:
rec.scan_url = ('%s/fp/box/%s' % (base, rec.id)) if rec.id else ''
# ------------------------------------------------------------------ workflow
def _set_state(self, new_state):
for rec in self:
old = dict(rec._fields['state'].selection).get(rec.state, rec.state)
new = dict(rec._fields['state'].selection).get(new_state, new_state)
rec.state = new_state
rec.message_post(body=_(
'Box %(n)s/%(N)s: %(old)s%(new)s by %(u)s'
) % {'n': rec.box_number, 'N': rec.box_count,
'old': old, 'new': new, 'u': self.env.user.name})
def action_set_racked(self):
self._set_state('racked')
def action_set_in_process(self):
self._set_state('in_process')
def action_set_packed(self):
self._set_state('packed')
def action_set_shipped(self):
self._set_state('shipped')
def action_set_lost(self):
self._set_state('lost')
def action_reset_received(self):
self._set_state('received')
def action_open_record(self):
"""Used by the /fp/box/<id> scan endpoint to land on the box form."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.box',
'res_id': self.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -86,6 +86,14 @@ class FpReceiving(models.Model):
'dropped off. Receiving is box count only — parts are '
'inspected by the racking crew when boxes are opened.',
)
box_ids = fields.One2many(
'fp.box', 'receiving_id', string='Tracked Boxes',
help='One record per physical box (box-level tracking). Auto-created '
'when the receiving is marked Counted.',
)
box_count_tracked = fields.Integer(
string='Boxes Tracked', compute='_compute_box_count_tracked',
)
expected_qty = fields.Integer(string='Expected Qty', help='Total quantity expected from the sale order.')
received_qty = fields.Integer(string='Received Qty', help='Total quantity actually received.')
qty_match = fields.Boolean(
@@ -1182,6 +1190,56 @@ class FpReceiving(models.Model):
# -------------------------------------------------------------------------
# Sub 8 — box-count-only actions (new primary flow)
# -------------------------------------------------------------------------
@api.depends('box_ids')
def _compute_box_count_tracked(self):
for rec in self:
rec.box_count_tracked = len(rec.box_ids)
def _fp_sync_boxes(self):
"""Create/sync one fp.box per received box (idempotent).
Grows the box set when box_count_in increases; removes only the
trailing boxes that are still 'received' when it shrinks (never
touches a box that has already advanced through the shop).
Resolves job_id from the SO's first job when one exists.
"""
Box = self.env['fp.box']
for rec in self:
n = int(rec.box_count_in or 0)
existing = rec.box_ids.sorted('box_number')
if existing:
existing.write({'box_count': n})
job = False
if rec.sale_order_id and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('sale_order_id', '=', rec.sale_order_id.id)], limit=1)
cur = len(existing)
if n > cur:
Box.create([{
'receiving_id': rec.id,
'box_number': i,
'box_count': n,
'job_id': job.id if job else False,
} for i in range(cur + 1, n + 1)])
elif n < cur:
drop = existing.filtered(
lambda b: b.box_number > n and b.state == 'received')
drop.unlink()
if job:
rec.box_ids.filtered(lambda b: not b.job_id).write({'job_id': job.id})
def action_view_boxes(self):
self.ensure_one()
return {
'name': _('Boxes'),
'type': 'ir.actions.act_window',
'res_model': 'fp.box',
'view_mode': 'list,form',
'domain': [('receiving_id', '=', self.id)],
'context': {'default_receiving_id': self.id,
'default_box_count': self.box_count_in or 1},
}
def action_mark_counted(self):
"""Receiver has counted the boxes on the dock. Move to Counted."""
for rec in self:
@@ -1197,6 +1255,7 @@ class FpReceiving(models.Model):
rec.message_post(body=_(
'%(user)s counted %(n)d box(es) at receiving.'
) % {'user': self.env.user.name, 'n': rec.box_count_in})
rec._fp_sync_boxes()
def action_mark_staged(self):
"""Deprecated 2026-05-20 — `staged` state was dead ceremony

View File

@@ -14,6 +14,9 @@ access_fp_racking_inspection_manager,fp.racking.inspection.manager,model_fp_rack
access_fp_racking_inspection_line_operator,fp.racking.inspection.line.operator,model_fp_racking_inspection_line,fusion_plating.group_fp_technician,1,1,1,1
access_fp_racking_inspection_line_supervisor,fp.racking.inspection.line.supervisor,model_fp_racking_inspection_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_racking_inspection_line_manager,fp.racking.inspection.line.manager,model_fp_racking_inspection_line,fusion_plating.group_fp_manager,1,1,1,1
access_fp_label_manual_wizard_operator,fp.label.manual.wizard.operator,model_fp_label_manual_wizard,fusion_plating.group_fp_technician,1,1,1,1
access_fp_label_generate_wizard_operator,fp.label.generate.wizard.operator,model_fp_label_generate_wizard,fusion_plating.group_fp_technician,1,1,1,1
access_fp_outbound_package_operator,fp.outbound.package.operator,model_fp_outbound_package,fusion_plating.group_fp_technician,1,1,1,1
access_fp_label_manual_wizard_receiver,fp.label.manual.wizard.receiver,model_fp_label_manual_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_label_manual_wizard_supervisor,fp.label.manual.wizard.supervisor,model_fp_label_manual_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_label_manual_wizard_manager,fp.label.manual.wizard.manager,model_fp_label_manual_wizard,fusion_plating.group_fp_manager,1,1,1,1
@@ -23,3 +26,6 @@ access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_f
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fp_manager,1,1,1,1
access_fp_box_operator,fp.box.operator,model_fp_box,fusion_plating.group_fp_technician,1,1,1,0
access_fp_box_supervisor,fp.box.supervisor,model_fp_box,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_box_manager,fp.box.manager,model_fp_box,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_racking_inspection_line_operator fp.racking.inspection.line.operator model_fp_racking_inspection_line fusion_plating.group_fp_technician 1 1 1 1
15 access_fp_racking_inspection_line_supervisor fp.racking.inspection.line.supervisor model_fp_racking_inspection_line fusion_plating.group_fp_shop_manager_v2 1 1 1 1
16 access_fp_racking_inspection_line_manager fp.racking.inspection.line.manager model_fp_racking_inspection_line fusion_plating.group_fp_manager 1 1 1 1
17 access_fp_label_manual_wizard_operator fp.label.manual.wizard.operator model_fp_label_manual_wizard fusion_plating.group_fp_technician 1 1 1 1
18 access_fp_label_generate_wizard_operator fp.label.generate.wizard.operator model_fp_label_generate_wizard fusion_plating.group_fp_technician 1 1 1 1
19 access_fp_outbound_package_operator fp.outbound.package.operator model_fp_outbound_package fusion_plating.group_fp_technician 1 1 1 1
20 access_fp_label_manual_wizard_receiver fp.label.manual.wizard.receiver model_fp_label_manual_wizard fusion_plating.group_fp_shop_manager_v2 1 1 1 1
21 access_fp_label_manual_wizard_supervisor fp.label.manual.wizard.supervisor model_fp_label_manual_wizard fusion_plating.group_fp_shop_manager_v2 1 1 1 1
22 access_fp_label_manual_wizard_manager fp.label.manual.wizard.manager model_fp_label_manual_wizard fusion_plating.group_fp_manager 1 1 1 1
26 access_fp_outbound_package_receiver fp.outbound.package.receiver model_fp_outbound_package fusion_plating.group_fp_shop_manager_v2 1 1 1 1
27 access_fp_outbound_package_supervisor fp.outbound.package.supervisor model_fp_outbound_package fusion_plating.group_fp_shop_manager_v2 1 1 1 1
28 access_fp_outbound_package_manager fp.outbound.package.manager model_fp_outbound_package fusion_plating.group_fp_manager 1 1 1 1
29 access_fp_box_operator fp.box.operator model_fp_box fusion_plating.group_fp_technician 1 1 1 0
30 access_fp_box_supervisor fp.box.supervisor model_fp_box fusion_plating.group_fp_shop_manager_v2 1 1 1 0
31 access_fp_box_manager fp.box.manager model_fp_box fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Box-level tracking — fp.box list / form / search / kanban + menu.
-->
<odoo>
<!-- ===== List ===== -->
<record id="fp_box_view_list" model="ir.ui.view">
<field name="name">fp.box.list</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<list string="Boxes" decoration-muted="state in ('shipped','cancelled')" decoration-danger="state == 'lost'">
<field name="box_number"/>
<field name="box_count"/>
<field name="name"/>
<field name="sale_order_id"/>
<field name="partner_id"/>
<field name="job_id"/>
<field name="location_note"/>
<field name="state" widget="badge"
decoration-info="state == 'received'"
decoration-warning="state in ('racked','in_process','packed')"
decoration-success="state == 'shipped'"
decoration-danger="state == 'lost'"/>
</list>
</field>
</record>
<!-- ===== Form ===== -->
<record id="fp_box_view_form" model="ir.ui.view">
<field name="name">fp.box.form</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_set_racked" type="object" string="Mark Racked"
class="btn-primary" invisible="state != 'received'"/>
<button name="action_set_in_process" type="object" string="Mark In Process"
class="btn-primary" invisible="state != 'racked'"/>
<button name="action_set_packed" type="object" string="Mark Packed"
class="btn-primary" invisible="state != 'in_process'"/>
<button name="action_set_shipped" type="object" string="Mark Shipped"
class="btn-primary" invisible="state != 'packed'"/>
<button name="action_set_lost" type="object" string="Flag Lost"
invisible="state in ('shipped','lost','cancelled')"/>
<button name="action_reset_received" type="object" string="Reset to Received"
groups="fusion_plating.group_fp_shop_manager_v2"
invisible="state == 'received'"/>
<field name="state" widget="statusbar"
statusbar_visible="received,racked,in_process,packed,shipped"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<label for="box_number" string="Box"/>
<div>
<field name="box_number" class="oe_inline"/> /
<field name="box_count" class="oe_inline"/>
</div>
<field name="receiving_id"/>
<field name="sale_order_id"/>
<field name="job_id"/>
</group>
<group>
<field name="partner_id"/>
<field name="location_note"/>
<field name="scan_url" widget="url" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Search ===== -->
<record id="fp_box_view_search" model="ir.ui.view">
<field name="name">fp.box.search</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="sale_order_id"/>
<field name="partner_id"/>
<field name="job_id"/>
<field name="receiving_id"/>
<filter name="open" string="Open (not shipped)" domain="[('state','not in',('shipped','cancelled'))]"/>
<filter name="received" string="Received" domain="[('state','=','received')]"/>
<filter name="in_process" string="In Process" domain="[('state','in',('racked','in_process','packed'))]"/>
<filter name="shipped" string="Shipped" domain="[('state','=','shipped')]"/>
<filter name="lost" string="Lost" domain="[('state','=','lost')]"/>
<group>
<filter name="g_state" string="Status" context="{'group_by':'state'}"/>
<filter name="g_customer" string="Customer" context="{'group_by':'partner_id'}"/>
<filter name="g_receiving" string="Receiving" context="{'group_by':'receiving_id'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Kanban (by status) ===== -->
<record id="fp_box_view_kanban" model="ir.ui.view">
<field name="name">fp.box.kanban</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<field name="state"/>
<templates>
<t t-name="card">
<div class="oe_kanban_content">
<strong><field name="name"/></strong>
<div><field name="partner_id"/></div>
<div t-if="record.location_note.raw_value">
<span class="text-muted">@ </span><field name="location_note"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_fp_box" model="ir.actions.act_window">
<field name="name">Boxes</field>
<field name="res_model">fp.box</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="fp_box_view_search"/>
<field name="context">{'search_default_open': 1}</field>
</record>
<!-- ===== Menu ===== -->
<menuitem id="menu_fp_box"
name="Boxes"
parent="menu_fp_receiving_root"
action="action_fp_box"
sequence="35"/>
</odoo>

View File

@@ -25,11 +25,15 @@
<!-- Renamed from "Receiving & Inspection" so the same dock workflow -->
<!-- — parts coming in AND parts going out — lives in one place. -->
<!-- Logistics module reparents its 5 menu items under this root. -->
<!-- 2026-06-02: opened to Technician (was Shop Manager+) so technicians
can browse + edit receiving in the backend, not just the tablet card.
All higher roles imply Technician, so they keep access; sales-only
roles (no Technician) stay excluded. Children inherit this gate. -->
<menuitem id="menu_fp_receiving_root"
name="Shipping &amp; Receiving"
parent="fusion_plating.menu_fp_root"
sequence="15"
groups="fusion_plating.group_fp_shop_manager_v2"/>
groups="fusion_plating.group_fp_technician"/>
<!-- Inbound (sequences 1030) -->
<menuitem id="menu_fp_receiving_all"

View File

@@ -125,6 +125,15 @@
</div>
<field name="x_fc_has_label_zpl" invisible="1"/>
</button>
<button name="action_view_boxes"
type="object"
class="oe_stat_button"
icon="fa-cubes"
invisible="box_count_tracked == 0">
<field name="box_count_tracked"
widget="statinfo"
string="Boxes"/>
</button>
</div>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-2"/>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.11.34.0',
'version': '19.0.11.35.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -295,7 +295,26 @@
</tr>
</thead>
<tbody>
<tr>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr style="page-break-inside: avoid;">
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference">
<br/><em t-esc="pl.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<tr t-if="not doc.part_line_ids" style="page-break-inside: avoid;">
<td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div>
@@ -303,11 +322,6 @@
<div><t t-esc="pid[2] or '-'"/></div>
</td>
<td>
<!-- Customer-facing description is the cert's
spec / certificate info (client request
2026-05-28). Falls back to the recipe-
derived process_description. spec_reference,
now optional, still prints below when set. -->
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference">

View File

@@ -274,7 +274,14 @@
<!-- Per-box loop: renders one sticker page per physical box in
the line/job qty. When _qty_total is missing/0/1, falls
back to a single render (no "X / N" indicator). -->
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
<!-- Hard safety cap (defense in depth): never render more than 100
label pages in one pass, regardless of what _qty_total resolves
to. A sticker is a per-box identification label; rendering
thousands (each with an inlined logo + QR data-URI) OOMs the
worker. WO-30072 (qty 2000 parts) crashed the PDF engine here. -->
<t t-set="_label_count_raw" t-value="int(_qty_total or 1)"/>
<t t-set="_label_count" t-value="100 if _label_count_raw &gt; 100 else (1 if _label_count_raw &lt; 1 else _label_count_raw)"/>
<t t-foreach="range(_label_count)" t-as="_box_idx0">
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
<div class="fp-sticker">
<!-- 3-cell header: Logo | WO# | QR -->
@@ -517,7 +524,13 @@
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<!-- One label per physical BOX (box_count_in on the
SO's receiving), NOT per part. Was
line.product_uom_qty, which rendered one label per
part and OOM'd on large qty (WO-30072 = 2000).
Falls back to 1 when no box count is recorded. -->
<t t-set="_box_count" t-value="int(sum(so.env['fp.receiving'].sudo().search([('sale_order_id', '=', so.id)]).mapped('box_count_in')) or 0) if 'fp.receiving' in so.env else 0"/>
<t t-set="_qty_total" t-value="_box_count if _box_count &gt; 0 else 1"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
@@ -572,7 +585,13 @@
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<!-- One label per physical BOX (box_count_in on the
SO's receiving), NOT per part. Was
line.product_uom_qty, which rendered one label per
part and OOM'd on large qty (WO-30072 = 2000).
Falls back to 1 when no box count is recorded. -->
<t t-set="_box_count" t-value="int(sum(so.env['fp.receiving'].sudo().search([('sale_order_id', '=', so.id)]).mapped('box_count_in')) or 0) if 'fp.receiving' in so.env else 0"/>
<t t-set="_qty_total" t-value="_box_count if _box_count &gt; 0 else 1"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description -->

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.36.2.0',
'version': '19.0.37.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """
@@ -109,6 +109,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
# ---- Racking panel (multi-rack split, Phase 1 — 2026-06-03) ----
# Loaded before job_workspace.js (which imports RackingPanel).
'fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss',
'fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml',
'fusion_plating_shopfloor/static/src/js/components/racking_panel.js',
# ---- Job Workspace (Phase 1 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',

View File

@@ -10,3 +10,4 @@ from . import workspace_controller
from . import landing_controller
from . import tablet_controller
from . import plant_kanban
from . import racking_controller

View File

@@ -124,8 +124,18 @@ class FpTabletMoveController(http.Controller):
hasattr(to_step, '_fp_should_block_predecessors')
and to_step._fp_should_block_predecessors()
):
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
# BETWEEN from_step and to_step blocks the move (you'd be skipping
# an incomplete intermediate stage). The from_step itself is
# in-progress BY DEFINITION when advancing partial parts out of
# it — counting it (or any earlier step) as an "unfinished
# predecessor" blocked every partial advance to a not-yet-started
# next step. Steps before from_step are irrelevant: the parts
# being moved are physically at from_step, ready for the next
# stage. Backward moves (rework: from > to) yield an empty range
# and are never predecessor-blocked.
unfinished = to_step.job_id.step_ids.filtered(
lambda s: s.sequence < to_step.sequence
lambda s: from_step.sequence < s.sequence < to_step.sequence
and s.state not in ('done', 'skipped', 'cancelled')
)
if unfinished:

View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Multi-rack splitting at Racking — Phase 1 controller. Endpoints run as the
# technician (request.env.user); the rack-load + division logic lives on
# fp.rack.load (core + fusion_plating_jobs extension).
from odoo import http
from odoo.http import request
from odoo.exceptions import UserError
class FpRackingController(http.Controller):
def _job(self, job_id):
return request.env['fp.job'].browse(int(job_id))
def _payload(self, job):
Load = request.env['fp.rack.load']
loads = Load._fp_job_loads(job)
total = Load._fp_racking_total(job)
return {
'ok': True,
'job_id': job.id,
'wo_name': job.display_wo_name,
'total': total,
'unassigned': max(total - sum(loads.mapped('qty_total')), 0),
'loads': [{
'id': load.id,
'name': load.name,
'rack_id': load.rack_id.id or False,
'rack_name': load.rack_id.name or '',
'rack_capacity': load.rack_id.capacity or 0,
'qty': load.qty_total,
'over_capacity': bool(
load.rack_id and load.rack_id.capacity
and load.qty_total > load.rack_id.capacity),
'moved': bool(load.current_step_id),
} for load in loads],
}
@http.route('/fp/racking/load', type='jsonrpc', auth='user')
def load(self, job_id):
job = self._job(job_id)
request.env['fp.rack.load']._fp_ensure_seeded(job)
return self._payload(job)
@http.route('/fp/racking/add_rack', type='jsonrpc', auth='user')
def add_rack(self, job_id):
job = self._job(job_id)
try:
request.env['fp.rack.load']._fp_add_rack(job)
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
return self._payload(job)
@http.route('/fp/racking/divide_equally', type='jsonrpc', auth='user')
def divide_equally(self, job_id):
job = self._job(job_id)
request.env['fp.rack.load']._fp_divide_equally(job)
return self._payload(job)
@http.route('/fp/racking/set_qty', type='jsonrpc', auth='user')
def set_qty(self, load_id, qty):
load = request.env['fp.rack.load'].browse(int(load_id))
job = load.line_ids[:1].job_id
try:
load._fp_set_qty(qty)
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
return self._payload(job)
@http.route('/fp/racking/remove_rack', type='jsonrpc', auth='user')
def remove_rack(self, load_id):
load = request.env['fp.rack.load'].browse(int(load_id))
job = load.line_ids[:1].job_id
try:
load._fp_remove_rack()
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
return self._payload(job)
@http.route('/fp/racking/assign_rack', type='jsonrpc', auth='user')
def assign_rack(self, load_id, rack_id):
load = request.env['fp.rack.load'].browse(int(load_id))
rack = request.env['fusion.plating.rack'].browse(int(rack_id))
load.rack_id = rack.id
if 'racking_state' in rack._fields:
rack.racking_state = 'loaded'
return self._payload(load.line_ids[:1].job_id)

View File

@@ -68,6 +68,18 @@ class FpWorkspaceController(http.Controller):
override = job.override_ids.filtered(
lambda o, n=step.recipe_node_id: o.node_id.id == n.id
) if 'override_ids' in job._fields else env['fp.job.node.override']
# Masking reference image(s)/PDF(s) attached at Express order entry.
# sudo: low-priv operators can read fp.job.step but not always the
# linked ir.attachment (rule 13m). The files are safe to surface.
mask_atts = (step.sudo().x_fc_masking_attachment_ids
if 'x_fc_masking_attachment_ids' in step._fields
else env['ir.attachment'])
mask_refs = [{
'id': a.id,
'name': a.name or '',
'mimetype': a.mimetype or '',
'is_image': (a.mimetype or '').startswith('image/'),
} for a in mask_atts]
steps.append({
'id': step.id,
'sequence': step.sequence,
@@ -75,6 +87,8 @@ class FpWorkspaceController(http.Controller):
'name': step.name or '',
'kind': step.kind or 'other',
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
# Drives the embedded rack-split panel inside this step's row.
'is_racking': step.area_kind == 'racking',
'state': step.state,
# Partial-order handling — parts currently parked at this
# step. Drives the "Send to next" button visibility + the
@@ -112,6 +126,7 @@ class FpWorkspaceController(http.Controller):
'quick_look_prompt_count': len(
getattr(step, 'quick_look_prompt_ids', step.browse())
),
'masking_refs': mask_refs,
})
# ---- Spec + attachments + chatter -------------------------------
@@ -288,6 +303,9 @@ class FpWorkspaceController(http.Controller):
'is_manager': env.user.has_group(
'fusion_plating.group_fusion_plating_manager',
),
# Note: the rack-split panel is gated per-step via each step's
# 'is_racking' flag (area_kind == 'racking'), embedded in the
# racking step's row — not a job-level panel.
}
# ======================================================================

View File

@@ -0,0 +1,53 @@
/** @odoo-module **/
// Racking panel — split a WO's parts across multiple racks (Phase 1).
// Lives on the Job Workspace, shown when the WO is at the Racking step.
import { Component, useState, onWillStart } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
export class RackingPanel extends Component {
static template = "fusion_plating_shopfloor.RackingPanel";
static props = ["jobId"];
setup() {
this.state = useState({ data: null, error: "", busy: false });
onWillStart(() => this.reload());
}
_apply(d) {
if (d && d.ok) {
this.state.data = d;
this.state.error = "";
} else {
this.state.error = (d && d.error) || "Something went wrong.";
}
}
async _call(route, params) {
if (this.state.busy) {
return;
}
this.state.busy = true;
try {
this._apply(await rpc(route, params));
} finally {
this.state.busy = false;
}
}
reload() {
return this._call("/fp/racking/load", { job_id: this.props.jobId });
}
addRack() {
return this._call("/fp/racking/add_rack", { job_id: this.props.jobId });
}
divideEqually() {
return this._call("/fp/racking/divide_equally", { job_id: this.props.jobId });
}
setQty(load, ev) {
const qty = parseInt(ev.target.value, 10) || 0;
return this._call("/fp/racking/set_qty", { load_id: load.id, qty });
}
removeRack(load) {
return this._call("/fp/racking/remove_rack", { load_id: load.id });
}
}

View File

@@ -30,18 +30,23 @@ import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog";
import { FpDamageDialog } from "./fp_damage_dialog";
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
import { RackingPanel } from "./components/racking_panel";
import { FpMovePartsDialog } from "./move_parts_dialog";
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
import { FileModel } from "@web/core/file_viewer/file_model";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog };
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.dialog = useService("dialog");
this.tabletSessionManager = useService("fp_tablet_session_manager");
// Full-screen image/PDF viewer (zoom + swipe) for masking refs.
this.fileViewer = useFileViewer();
this.state = useState({
data: null,
@@ -69,7 +74,7 @@ export class FpJobWorkspace extends Component {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
target: "main",
});
return;
}
@@ -82,7 +87,7 @@ export class FpJobWorkspace extends Component {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
target: "main",
});
return;
}
@@ -164,16 +169,16 @@ export class FpJobWorkspace extends Component {
// ---- Navigation --------------------------------------------------------
onBack() {
// The workspace is opened with target: "current" which REPLACES
// the current action and wipes the backstack. Navigate explicitly
// to the plant kanban — the sole Shop Floor surface as of
// 2026-05-25 (fp_shopfloor_landing was retired the same day).
// See CLAUDE.md Critical Rule 21 + the "Legacy-action redirect"
// section.
// target: "main" CLEARS the breadcrumb stack (Odoo 19:
// action.target === "main" => clearBreadcrumbs in action_service.js).
// target: "current" was APPENDING — each kanban<->workspace switch
// grew the /odoo/... URL, and lock/unlock window.location.reload()
// preserved it, so the address bar ballooned. "main" keeps the URL a
// single action. The plant kanban is the sole Shop Floor surface.
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
target: "main",
});
}
@@ -199,6 +204,24 @@ export class FpJobWorkspace extends Component {
});
}
// Open masking reference image(s)/PDF(s) in the full-screen viewer.
// Builds FileModel descriptors so the operator gets zoom + swipe across
// every reference on this step, starting at the tile they tapped.
openMaskRef(step, ref) {
const files = (step.masking_refs || []).map((r) => {
const f = new FileModel();
f.id = r.id;
f.name = r.name;
f.mimetype = r.mimetype;
f.type = "binary";
return f;
});
const clicked = files.find((f) => f.id === ref.id) || files[0];
if (clicked) {
this.fileViewer.open(clicked, files);
}
}
// ---- Step state helpers ------------------------------------------------
iconForStepState(state) {
const map = {
@@ -527,6 +550,17 @@ export class FpJobWorkspace extends Component {
async onReceivingBoxCountBlur(rcv, ev) {
const newVal = parseInt(ev.target.value, 10) || 0;
await this._saveReceivingBoxCount(rcv, newVal);
}
// +/- stepper on the Boxes-received field. Clamps at 0 and reuses the
// same persist path as the typed-in blur handler.
async onReceivingBoxStep(rcv, delta) {
const newVal = Math.max(0, (rcv.box_count_in || 0) + delta);
await this._saveReceivingBoxCount(rcv, newVal);
}
async _saveReceivingBoxCount(rcv, newVal) {
if (newVal === (rcv.box_count_in || 0)) return;
rcv.box_count_in = newVal;
try {

View File

@@ -212,7 +212,7 @@ export class FpPlantKanban extends Component {
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: res.id },
target: "current",
target: "main",
});
return; // navigating away — skip the refresh
} else if (res.model === "fp.job.step") {
@@ -223,7 +223,7 @@ export class FpPlantKanban extends Component {
job_id: res.job_id || 0,
focus_step_id: res.id,
},
target: "current",
target: "main",
});
return;
} else if (res.action_tag) {
@@ -232,7 +232,7 @@ export class FpPlantKanban extends Component {
type: "ir.actions.client",
tag: res.action_tag,
params: res.action_params || {},
target: "current",
target: "main",
});
return;
} else {

View File

@@ -0,0 +1,70 @@
// Racking panel (Job Workspace) — split a WO across racks. Self-contained
// tokens with a compile-time dark-mode branch (Odoo 19 compiles this file
// into both web.assets_backend and web.assets_web_dark).
$o-webclient-color-scheme: bright !default;
$_rkp-card-hex: #ffffff;
$_rkp-border-hex: #d8dadd;
$_rkp-text-hex: #1d1f1e;
$_rkp-page-hex: #f3f4f6;
@if $o-webclient-color-scheme == dark {
$_rkp-card-hex: #22262d !global;
$_rkp-border-hex: #3a3f47 !global;
$_rkp-text-hex: #e6e6e6 !global;
$_rkp-page-hex: #1a1d21 !global;
}
.o_fp_racking_panel {
background: $_rkp-card-hex;
border: 1px solid $_rkp-border-hex;
border-left: 4px solid #0071e3;
border-radius: 8px;
padding: 0.7rem 0.9rem;
margin-bottom: 0.7rem;
color: $_rkp-text-hex;
.o_fp_rkp_head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.o_fp_rkp_title { font-weight: 700; }
.o_fp_rkp_unassigned {
font-size: 0.85rem;
color: #1d6e2f;
&.has { color: #b06600; font-weight: 600; }
}
.o_fp_rkp_err {
color: #b00018;
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.o_fp_rkp_row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
border-bottom: 1px solid $_rkp-border-hex;
&:last-of-type { border-bottom: 0; }
&.over { border-left: 3px solid #ff9f0a; padding-left: 0.4rem; }
}
.o_fp_rkp_rk { flex: 1; font-weight: 600; }
.o_fp_rkp_qty {
width: 6rem;
text-align: center;
font-weight: 600;
background: $_rkp-page-hex;
}
.o_fp_rkp_cap { color: #888; font-size: 0.85rem; }
.o_fp_rkp_actions {
display: flex;
gap: 0.5rem;
margin-top: 0.6rem;
flex-wrap: wrap;
}
}

View File

@@ -219,19 +219,38 @@ $_ws-text-hex: #1d1d1f;
grid-template-columns: 1.7fr 1fr;
overflow: hidden;
@media (max-width: 900px) { grid-template-columns: 1fr; }
// Single column on tablets/phones, and make MAIN itself the one scroll
// container — the work (steps/receiving) sits at the top, Notes stack
// below and scroll into view when needed. The old layout kept
// overflow:hidden with two nested auto-height scroll panes, which
// clipped the notes and broke scrolling on narrow screens.
@media (max-width: 900px) {
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
}
.o_fp_ws_steps {
padding: 0.7rem 1rem;
overflow-y: auto;
border-right: 1px solid $_ws-border-hex;
// MAIN owns the scroll on narrow screens; don't nest a second one.
@media (max-width: 900px) {
overflow-y: visible;
border-right: none;
}
}
.o_fp_ws_side {
padding: 0.7rem 1rem;
overflow-y: auto;
background: $_ws-page-hex;
@media (max-width: 900px) { overflow-y: visible; }
}
.o_fp_ws_empty {
@@ -298,6 +317,89 @@ $_ws-text-hex: #1d1d1f;
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--bs-secondary-color, #555); font-style: italic; }
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
// ---- Masking reference tiles (tap → full-screen FileViewer) -----------
.o_fp_ws_mask_refs {
margin-top: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px solid #d97706;
border-left: 4px solid #f59e0b;
border-radius: 8px;
background: rgba(245, 158, 11, 0.08);
}
.o_fp_ws_mask_refs_label {
font-size: 0.78rem;
font-weight: 600;
color: #b06600;
margin-bottom: 0.4rem;
i { margin-right: 0.3rem; }
}
.o_fp_ws_mask_refs_grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.o_fp_ws_mask_ref {
position: relative;
width: 104px;
height: 104px;
border: 1px solid $_ws-border-hex;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: $_ws-card-hex;
transition: transform 0.08s ease, box-shadow 0.08s ease;
&:hover {
transform: scale(1.04);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
border-color: #f59e0b;
}
}
.o_fp_ws_mask_ref_thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.o_fp_ws_mask_ref_pdf {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.3rem;
text-align: center;
i { font-size: 1.8rem; color: #d9534f; }
}
.o_fp_ws_mask_ref_pdfname {
font-size: 0.6rem;
color: $_ws-text-hex;
line-height: 1.1;
word-break: break-word;
max-height: 2.2em;
overflow: hidden;
}
.o_fp_ws_mask_ref_zoom {
position: absolute;
right: 4px;
bottom: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
}
@if $o-webclient-color-scheme == dark {
.o_fp_ws_mask_refs_label { color: #f0a93a; }
}
.o_fp_ws_step_excluded {
font-size: 0.78rem;
color: var(--bs-secondary-color, #888);
@@ -385,6 +487,61 @@ $_ws-text-hex: #1d1d1f;
flex-wrap: wrap;
}
// ===== Phone optimization (2026-06-02) ==============================
// Shrink the fixed chrome (header + workflow bar + rail) so the operator
// sees the actual work (receiving / step cards) without scrolling. Notes
// sit below the work in the single scroll column — present, scroll for more.
@media (max-width: 600px) {
.o_fp_ws_head {
padding: 0.45rem 0.7rem;
gap: 0.4rem 0.6rem;
}
.o_fp_ws_head_l, .o_fp_ws_head_r { gap: 0.35rem; }
.o_fp_ws_wo { font-size: 1.05rem; }
.o_fp_ws_cust, .o_fp_ws_part { font-size: 0.85rem; }
.o_fp_ws_back, .o_fp_ws_handoff { padding: 0.4rem 0.7rem; font-size: 0.85rem; }
.o_fp_ws_pill { padding: 0.2rem 0.5rem; font-size: 0.76rem; }
// Workflow bar: tighter + horizontally scrollable so every stage is
// reachable (it was clipped) while taking far less vertical room.
.o_fp_ws_bar { padding: 0.4rem 0.7rem; gap: 0.5rem; }
.o_fp_ws_bar_line {
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.o_fp_ws_dot_wrap { min-width: 56px; }
.o_fp_ws_dot_wrap .o_fp_ws_bar_dot { width: 14px; height: 14px; }
.o_fp_ws_dot_wrap .o_fp_ws_bar_label { font-size: 0.66rem; margin-top: 0.2rem; }
.o_fp_ws_next { white-space: nowrap; }
// Work + notes stack tighter; rail stays tappable but compact.
.o_fp_ws_steps, .o_fp_ws_side { padding: 0.5rem 0.6rem; }
.o_fp_ws_rail { padding: 0.45rem 0.6rem; gap: 0.4rem; }
// Receiving card part lines: stack vertically so the Received input and
// the Good/Damaged select wrap INSIDE the card instead of overflowing
// off the right edge. The part description now wraps across full width.
.o_fp_ws_rcv { padding: 0.7rem; }
.o_fp_ws_rcv_line {
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
}
.o_fp_ws_rcv_line_part { min-width: 0; overflow-wrap: anywhere; }
.o_fp_ws_rcv_line_qty {
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.o_fp_ws_rcv_line_qty label { flex: 1 1 8rem; }
.o_fp_ws_rcv_qty_input { width: auto; flex: 1 1 4rem; min-width: 3.5rem; }
.o_fp_ws_rcv_cond_select { width: auto; flex: 1 1 7rem; min-width: 6rem; }
// Shipping panel fields already wrap; keep them inside the card too.
.o_fp_ws_ship_fields label { min-width: 0; }
}
// =============================================================================
// SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
// =============================================================================
@@ -519,6 +676,45 @@ $_ws-text-hex: #1d1d1f;
text-align: center;
}
// +/- stepper around the Boxes-received input (and any future numeric step
// field). Big touch targets; the input grows to fill between the buttons.
.o_fp_ws_stepper {
display: flex;
align-items: stretch;
gap: 0.4rem;
max-width: 18rem;
.o_fp_ws_rcv_box_input {
flex: 1 1 auto;
max-width: none; // override the standalone 12rem cap inside the stepper
margin: 0;
}
}
.o_fp_ws_stepper_btn {
flex: 0 0 auto;
width: 3rem;
min-height: 3rem; // comfortable touch target
border: 1px solid $_ws-border-hex;
border-radius: 8px;
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
color: $_ws-text-hex;
font-size: 1.2rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s ease, background 0.1s ease, box-shadow 0.1s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.12);
}
&:active { transform: scale(0.95); }
}
.o_fp_ws_rcv_lines {
background: $_ws-page-hex;
border-radius: 6px;
@@ -806,20 +1002,25 @@ $_ws-text-hex: #1d1d1f;
gap: 1rem;
}
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
// definitions in the compiled bundle — they're SCSS literals + two
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
// resolves to the dark #hex fallback, in light AND dark mode. The fix
// for dialog text is to INHERIT the modal's theme-correct colour (the
// dialog title and the "Count the Parts" list items do exactly this and
// are readable in both modes). Tinted boxes use translucent rgba() so
// they work over whatever the live theme background is.
.o_fp_finish_block_step {
font-size: 1.1rem;
color: var(--bs-body-color);
// Amber wash over the live theme bg — pale in light mode, dark-amber
// in dark mode (the -bg-subtle/-text-emphasis BS vars aren't defined
// in Odoo's bootstrap, so color-mix is the dark-aware path).
background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg));
background-color: rgba(245, 158, 11, 0.16);
padding: 0.7rem 1rem;
border-radius: 6px;
border-left: 4px solid #f59e0b;
}
.o_fp_finish_block_msg {
color: var(--bs-secondary-color, #333);
font-weight: 500;
}
.o_fp_finish_block_list {
@@ -834,9 +1035,9 @@ $_ws-text-hex: #1d1d1f;
}
.o_fp_finish_block_action_note {
color: var(--bs-secondary-color, #555);
// Inherit text colour; translucent neutral box works in both themes.
font-style: italic;
padding: 0.6rem 0.8rem;
background: var(--bs-tertiary-bg, #f3f4f6);
background: rgba(128, 128, 128, 0.12);
border-radius: 4px;
}

View File

@@ -3,11 +3,16 @@
.o_fp_plant_kanban {
padding: 8px;
background: $plant-bg;
// Full viewport height + flex column so .board can grow to fill all
// remaining vertical space. min-height: 100vh would let .board's
// intrinsic height bubble up and put the horizontal scrollbar
// mid-page; height + flex pins the scrollbar to the viewport bottom.
height: 100vh;
// Fill the Odoo action area (below the navbar) and own the scroll
// internally — NOT 100vh. 100vh is taller than the available area by
// the navbar height, so the board bottom + its horizontal scrollbar
// overflowed off-screen and scrolling broke — badly on phones, where
// Odoo also re-lays-out at the md breakpoint and the scroll gets lost
// up the tree. height:100% + internal overflow is the same pattern
// job_workspace / manager_dashboard / .o_fp_tablet use. flex column so
// .board fills the remaining space under the sticky header.
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
color: $plant-text;
@@ -119,7 +124,26 @@
// 8 tiles — Work Orders, At My Station, Bakes Due, On Hold,
// Awaiting QC, Awaiting CoC, Ready to Ship, Overdue.
.kpi-strip { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; }
.kpi-strip {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
// 8 tiles can't fit a narrow row. Drop to 4-up on tablets, then on
// phones make it one horizontally-scrollable row so the header stays
// short and the board keeps the screen.
@media (max-width: 1180px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 600px) {
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: 42%;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
padding-bottom: 2px;
> * { scroll-snap-align: start; }
}
}
.search-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.search-input {
@@ -156,7 +180,23 @@
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
overflow-y: hidden; // columns scroll internally, not the board
overscroll-behavior-x: contain; // don't bounce the whole page sideways
-webkit-overflow-scrolling: touch;
padding-bottom: 4px; // room for the horizontal scrollbar
// Tablet: slightly narrower columns so more fit per swipe.
@media (max-width: 900px) {
grid-template-columns: repeat(9, minmax(260px, 1fr));
}
// Phone: one full-width stage per screen; swipe between stages.
// 86vw leaves a peek of the next column so it's clearly scrollable.
// Cards are width:100% so they never overflow the narrower column.
@media (max-width: 600px) {
grid-template-columns: repeat(9, 86vw);
gap: 10px;
scroll-snap-type: x proximity;
> .col { scroll-snap-align: start; }
}
}
// Each .col is now a proper bordered card that runs full board
// height — same visual treatment as Trello / Asana columns. The
@@ -236,3 +276,29 @@
}
}
}
// ===== Responsive — phones / small screens (2026-06-02) ==============
// Compact the header so the board keeps the screen, and make the toolbar
// controls full-width + tappable (>=40px) on a phone.
.o_fp_plant_kanban {
@media (max-width: 600px) {
padding: 6px;
.floor-header { padding: 8px; margin-bottom: 6px; gap: 6px; }
.floor-title { font-size: 15px; }
.floor-header-top { gap: 8px; }
.floor-controls { gap: 5px; width: 100%; }
// 3-segment mode toggle spans the row; each segment stays tappable.
.mode-toggle { flex: 1 1 100%; }
.mode-toggle .mode-btn { flex: 1 1 0; padding: 10px 6px; }
.station-picker,
.toolbar-btn { padding: 10px 12px; }
// Search fills its own row; filter chips wrap underneath.
.search-row { gap: 6px; }
.search-input { min-width: 0; flex: 1 1 100%; }
}
}

Some files were not shown because too many files have changed in this diff Show More