Compare commits

...

55 Commits

Author SHA1 Message Date
gsinghpal
3fe5d5c17c test(fusion_plating_shopfloor): sign-off tests use the authenticated admin + a recipe link
Clone-verify fixes: the HTTP request runs as base.user_admin, so set/read
x_fc_signature_image on that user (not self.env.user / uid 1); give the step a
recipe_node_id so button_finish passes the S21 no-recipe-link gate (also fixes
the pre-existing test_sign_off_finishes_step). 5/5 pass on an entech clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:37:01 -04:00
gsinghpal
190b394001 feat(fusion_plating_shopfloor): workspace sign-off confirms saved signature, draws only when absent
onFinishStep: if the user has a saved Plating Signature, show FpSignatureConfirm
(one-tap, preview); otherwise open the draw-pad. Factored _openSignaturePad +
_commitSignOff (sends null data URI when using the saved signature).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:22:42 -04:00
gsinghpal
b5a300f439 feat(fusion_plating_shopfloor): FpSignatureConfirm dialog + asset registration
Confirm-with-preview dialog (saved-signature preview + Sign & Finish + Use a
different signature). Registered after the signature_pad assets; version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:21:36 -04:00
gsinghpal
25ef7832f5 feat(fusion_plating_shopfloor): sign_off reuses+persists Plating Signature; load exposes it
/fp/workspace/sign_off: signature_data_uri now optional; a supplied drawing
persists to res.users.x_fc_signature_image (SELF_WRITEABLE) and the wasted
per-step ir.attachment is dropped; no drawing + a saved signature just finishes.
/fp/workspace/load exposes user_has_plating_signature + user_plating_signature.
Merged 3 new tests into the existing TestWorkspaceSignOff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:20:35 -04:00
gsinghpal
600e11fabb docs(fusion_plating_shopfloor): implementation plan - reuse saved Plating Signature
4 tasks: backend (load payload + sign_off persist/drop-attachment + HttpCase
tests) -> FpSignatureConfirm component + manifest -> job_workspace confirm-vs-draw
wiring -> entech clone-verify. Isolated worktree off main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:14:14 -04:00
gsinghpal
5e3e6b5319 docs(fusion_plating_shopfloor): spec - reuse saved Plating Signature on sign-off
Shop-floor sign-off currently makes operators redraw a signature every
time, and the drawing is discarded (reports read x_fc_signature_image).
Spec: use the saved Plating Signature (one-tap confirm-with-preview);
draw once when absent and persist it to x_fc_signature_image so future
sign-offs + reports reuse it. Tablet-workspace scope; no model/migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:06:10 -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
106 changed files with 5772 additions and 261 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,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,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

@@ -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,412 @@
# Shop-Floor Sign-Off: Reuse Saved Plating Signature — 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:** Make shop-floor step sign-off reuse the operator's saved Plating Signature (one-tap confirm) instead of redrawing every time; capture-and-persist it the first time.
**Architecture:** The `/fp/workspace/load` payload exposes whether the user has a Plating Signature + the image; `job_workspace.js` shows a confirm-with-preview dialog when they do (new `FpSignatureConfirm`) and the existing `FpSignaturePad` when they don't; `/fp/workspace/sign_off` persists any drawing to `res.users.x_fc_signature_image` and drops the wasted per-step attachment.
**Tech Stack:** Odoo 19 (`fusion_plating_shopfloor`), OWL components, JSON-RPC controller, `HttpCase` tests.
---
## Working location (IMPORTANT — isolated worktree)
All work happens in the worktree **`K:\Github\Odoo-Modules-signoff-wt`** on branch **`feat/shopfloor-signoff-reuse-signature`** (off `main`). Use absolute paths under that dir for Read/Edit; for git use `git -C "K:\Github\Odoo-Modules-signoff-wt" ...` (tracked prefix `fusion_plating/`). The main checkout is in use by another session — do not touch it.
## Testing model
`fusion_plating_shopfloor` can't install on the local Community box — the `HttpCase` tests run on an Enterprise env (entech clone), like the WO-grouping deploy. Local per-task gate:
- Python: `python -m pyflakes "<file>"` (host).
- XML: `python -c "import xml.etree.ElementTree as ET; ET.parse(r'<file>'); print('XML OK')"`.
- JS (ESM): `node --check` rejects `import` on a `.js`; copy to a temp `.mjs` first: `Copy-Item <file> $env:TEMP\x.mjs; node --check $env:TEMP\x.mjs` (skip if `node` absent — the asset-bundle compile during the clone-verify `-u` is the real gate).
- SCSS: no local check; Odoo compiles it on `-u` (clone-verify catches errors).
## File structure
| File | Module | Responsibility |
|------|--------|----------------|
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | shopfloor | `load` payload keys; `sign_off` persist + drop attachment. |
| `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | shopfloor | NEW confirm dialog component. |
| `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | shopfloor | NEW template. |
| `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | shopfloor | NEW styling. |
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | shopfloor | confirm-vs-draw wiring. |
| `fusion_plating_shopfloor/__manifest__.py` | shopfloor | register 3 assets + version bump. |
| `fusion_plating_shopfloor/tests/test_workspace_controller.py` | shopfloor | new HttpCase tests. |
**Build order:** backend (payload + sign_off + tests) → new component + manifest → workspace wiring → version bump + static checks → clone-verify.
---
### Task 1: Backend — load payload + sign_off rewrite + tests
**Files:**
- Modify: `fusion_plating_shopfloor/controllers/workspace_controller.py` (load return dict ~line 241; `sign_off` ~line 450-494)
- Test: `fusion_plating_shopfloor/tests/test_workspace_controller.py`
- [ ] **Step 1: Add the load payload keys.** In `workspace_controller.py`, the `load` method's `return {` dict starts with `'ok': True,` (around line 241-242). Insert these two keys immediately after the `'ok': True,` line, at the same indentation:
```python
'user_has_plating_signature': bool(env.user.x_fc_signature_image),
'user_plating_signature': (
('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode())
if env.user.x_fc_signature_image else ''
),
```
(`env` is already bound at the top of `load`. `x_fc_signature_image` is in `SELF_READABLE_FIELDS`, so reading `env.user`'s own value is allowed.)
- [ ] **Step 2: Rewrite `sign_off`.** Replace the entire `sign_off` method (the `@http.route('/fp/workspace/sign_off', ...)` decorator + method, lines ~450-494) with:
```python
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri=None):
env = request.env
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'}
sig = (signature_data_uri or '').strip()
user = env.user
if sig:
# A drawing was supplied (first-time, or "use a different
# signature"). Persist it as the user's Plating Signature so
# every future sign-off + report reuses it. x_fc_signature_image
# is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed.
if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1]
try:
user.write({'x_fc_signature_image': sig})
except Exception:
_logger.exception(
"workspace/sign_off: persisting Plating Signature failed for uid %s",
env.uid,
)
return {'ok': False, 'error': 'Failed to save your signature.'}
elif not user.x_fc_signature_image:
# No drawing AND no saved signature — nothing to sign with.
return {
'ok': False,
'error': 'A signature is required. Draw one to continue.',
}
try:
step.button_finish()
except Exception as exc:
_logger.exception("workspace/sign_off: button_finish failed")
return {'ok': False, 'error': str(exc)}
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
return {'ok': True, 'step_id': step.id, 'state': step.state}
```
(Note: `signature_data_uri` is now optional; the per-step `ir.attachment` create is gone.)
- [ ] **Step 3: Write the tests.** Append to `fusion_plating_shopfloor/tests/test_workspace_controller.py` (the file already defines `_rpc`, `_TINY_PNG_B64`, and the `@tagged` decorator at the top — reuse them):
```python
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceSignOff(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/SIG001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 3,
})
def test_load_exposes_plating_signature_flags(self):
self.env.user.x_fc_signature_image = False
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
self.assertFalse(res['user_has_plating_signature'])
self.assertEqual(res['user_plating_signature'], '')
self.env.user.x_fc_signature_image = _TINY_PNG_B64
res2 = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
self.assertTrue(res2['user_has_plating_signature'])
self.assertTrue(
res2['user_plating_signature'].startswith('data:image/png;base64,'))
def test_sign_off_without_signature_and_no_saved_errors(self):
self.env.user.x_fc_signature_image = False
step = self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'Final', 'sequence': 10})
res = _rpc(self, '/fp/workspace/sign_off', step_id=step.id)
self.assertFalse(res['ok'])
self.assertIn('signature', res['error'].lower())
def test_sign_off_with_drawing_persists_signature_and_no_attachment(self):
self.env.user.x_fc_signature_image = False
step = self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'Final', 'sequence': 10})
data_uri = 'data:image/png;base64,' + _TINY_PNG_B64
# button_finish may fail on this un-started step; we assert the
# signature-persist + no-attachment side effects, which happen first.
_rpc(self, '/fp/workspace/sign_off',
step_id=step.id, signature_data_uri=data_uri)
self.env.user.invalidate_recordset(['x_fc_signature_image'])
self.assertTrue(
self.env.user.x_fc_signature_image,
'drawing persisted to the Plating Signature')
n = self.env['ir.attachment'].search_count([
('res_model', '=', 'fp.job.step'), ('res_id', '=', step.id)])
self.assertEqual(n, 0, 'no per-step signature attachment is created')
```
- [ ] **Step 4: Static check.** Run:
```
python -m pyflakes "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\controllers\workspace_controller.py" "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\tests\test_workspace_controller.py"
```
Expected: clean (ignore pre-existing warnings on lines you didn't touch).
- [ ] **Step 5: Commit.**
```
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py fusion_plating/fusion_plating_shopfloor/tests/test_workspace_controller.py
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): sign_off reuses+persists Plating Signature; load exposes it"
```
---
### Task 2: New `FpSignatureConfirm` component + manifest registration
**Files:**
- Create: `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js`
- Create: `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml`
- Create: `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss`
- Modify: `fusion_plating_shopfloor/__manifest__.py` (assets list, after the `signature_pad.*` block ~line 81; version)
- [ ] **Step 1: Create the JS component.**
```js
/** @odoo-module **/
// =============================================================================
// Fusion Plating — SignatureConfirm
//
// Confirm dialog shown when the operator already has a saved Plating
// Signature: previews it + "Sign & Finish" (props.onConfirm) or "Use a
// different signature" (props.onRedraw, opens the draw-pad). No drawing here.
// =============================================================================
import { Component } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class FpSignatureConfirm extends Component {
static template = "fusion_plating_shopfloor.SignatureConfirm";
static components = { Dialog };
static props = {
close: Function, // dialog service injects
title: { type: String, optional: true },
contextLabel: { type: String, optional: true },
signatureUrl: { type: String }, // data: URI of saved sig
onConfirm: { type: Function }, // () => commit (no drawing)
onRedraw: { type: Function }, // () => open draw-pad
};
onConfirm() {
this.props.onConfirm();
this.props.close();
}
onRedraw() {
this.props.onRedraw();
this.props.close();
}
onCancel() {
this.props.close();
}
}
```
- [ ] **Step 2: Create the XML template.**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
<Dialog title="props.title or 'Confirm signature'" size="'md'">
<div class="o_fp_sig_confirm">
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
<t t-esc="props.contextLabel"/>
</div>
<div class="o_fp_sig_preview">
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
</div>
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
<button class="btn btn-primary" t-on-click="onConfirm">Sign &amp; Finish</button>
</t>
</Dialog>
</t>
</templates>
```
- [ ] **Step 3: Create the SCSS.**
```scss
// Confirm-with-preview dialog for shop-floor sign-off. Explicit hex per the
// project card-styling rule (don't rely on var(--bs-border-color)).
.o_fp_sig_confirm {
.o_fp_sig_ctx {
font-size: 0.85rem;
color: #555;
margin-bottom: 8px;
}
.o_fp_sig_preview {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
padding: 8px;
background-color: #ffffff;
border: 1px solid #d8dadd;
border-radius: 4px;
img {
max-width: 100%;
max-height: 160px;
}
}
.o_fp_sig_hint {
text-align: center;
margin-top: 6px;
font-size: 0.85rem;
color: #555;
}
}
```
- [ ] **Step 4: Register assets + bump version** in `__manifest__.py`. Immediately after the three `signature_pad.*` lines (the `.scss`, `.xml`, `.js` block ending ~line 81), insert:
```python
'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js',
```
And change `'version': '19.0.37.1.0',``'version': '19.0.37.2.0',`.
- [ ] **Step 5: Static checks.**
```
python -c "import xml.etree.ElementTree as ET; ET.parse(r'K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\static\src\xml\components\signature_confirm.xml'); print('XML OK')"
```
Expected: `XML OK`. (Optional JS check: copy `signature_confirm.js` to `$env:TEMP\x.mjs` and `node --check` it if `node` is present.)
- [ ] **Step 6: Commit.**
```
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_confirm.js fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss fusion_plating/fusion_plating_shopfloor/__manifest__.py
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): FpSignatureConfirm dialog + asset registration"
```
---
### Task 3: Wire confirm-vs-draw into `job_workspace.js`
**Files:**
- Modify: `fusion_plating_shopfloor/static/src/js/job_workspace.js` (import ~line 27; `static components` ~line 41; `onFinishStep` ~line 364-392)
- [ ] **Step 1: Import the new component.** After the existing `import { FpSignaturePad } from "./components/signature_pad";` (line 27), add:
```js
import { FpSignatureConfirm } from "./components/signature_confirm";
```
- [ ] **Step 2: Register it in `static components`.** In the `static components = { ... };` line (~41), add `FpSignatureConfirm` to the set (e.g. right after `FpSignaturePad`):
```js
static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
```
- [ ] **Step 3: Replace `onFinishStep` and add two helpers.** Replace the whole `onFinishStep(step)` method (currently lines ~364-392, the `if (step.requires_signoff) { this.dialog.add(FpSignaturePad, {...}); return; } await this._callFinishStep(step, false);`) with:
```js
async onFinishStep(step) {
if (step.requires_signoff) {
if (this.state.data.user_has_plating_signature) {
// One-tap confirm with preview of the saved Plating Signature.
this.dialog.add(FpSignatureConfirm, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
signatureUrl: this.state.data.user_plating_signature,
onConfirm: () => this._commitSignOff(step, null), // use saved
onRedraw: () => this._openSignaturePad(step), // draw a new one
});
} else {
// First time — draw once; the backend persists it.
this._openSignaturePad(step);
}
return;
}
// Plain finish — routes through /fp/workspace/finish_step which
// returns structured errors so we can show the FpFinishBlockDialog.
await this._callFinishStep(step, /* bypass */ false);
}
_openSignaturePad(step) {
this.dialog.add(FpSignaturePad, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: (dataUri) => this._commitSignOff(step, dataUri),
});
}
async _commitSignOff(step, dataUri) {
try {
const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id,
signature_data_uri: dataUri, // null -> backend uses the saved signature
});
if (res && res.ok) {
this.notification.add("Step signed off and finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
```
(`fpRpc`, `this.dialog`, `this.notification`, `this.refresh`, `this._callFinishStep` all already exist in this component — verify the imports/usages are unchanged.)
- [ ] **Step 4: Static check (optional JS).** Copy `job_workspace.js` to `$env:TEMP\x.mjs` and `node --check $env:TEMP\x.mjs` if `node` is present; otherwise rely on the clone-verify asset compile.
- [ ] **Step 5: Commit.**
```
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): workspace sign-off confirms saved signature, draws only when absent"
```
---
### Task 4: Verify on an entech clone
**Files:** none (verification only). Mirror the WO-grouping clone-verify recipe.
- [ ] **Step 1: Clone + upgrade + tests.** On entech: clone `admin` → throwaway UTF-8 DB (`createdb -O odoo -E UTF8 -T template0 --lc-collate=C --lc-ctype=C`, then `pg_dump admin | psql`), stage this branch's `fusion_plating_shopfloor` files into `/mnt/extra-addons/custom/fusion_plating_shopfloor`, then:
```
odoo -c /etc/odoo/odoo.conf -d <clone> -u fusion_plating_shopfloor --test-enable \
--test-tags /fusion_plating_shopfloor:TestWorkspaceSignOff --stop-after-init \
--workers=0 --http-port=0 --gevent-port=0 --log-level=test
```
Expected: exit 0; the 3 new tests pass. (Run the full `/fusion_plating_shopfloor` suite + a baseline diff if any failures appear, to confirm they're pre-existing — same technique as the WO-grouping deploy.)
- [ ] **Step 2: Asset compile sanity.** Confirm the `-u` compiled the backend bundle without SCSS/XML errors (no `CRITICAL`/`Failed to load` for `signature_confirm`).
- [ ] **Step 3: Browser smoke (clone or post-deploy).** As a tech with **no** Plating Signature: finish a `requires_signoff` step → draw-pad appears → draw → their `x_fc_signature_image` is set (query DB). Finish another sign-off step → the **confirm-with-preview** dialog appears (no pad) → Sign & Finish works. Render that job's WO Detail → the saved signature shows.
- [ ] **Step 4: Mark complete.** Suite green + smoke confirmed → ready to deploy `fusion_plating_shopfloor` to entech (standard recipe: backup, stage, `-u`, cache-bust, restart, gated on exit 0).
---
## Self-review (by plan author)
- **Spec coverage:** load payload keys (Task 1) ✓; sign_off optional URI + persist + drop attachment (Task 1) ✓; `FpSignatureConfirm` (Task 2) ✓; workspace confirm-vs-draw + "use a different signature" replaces saved (Task 3) ✓; manifest assets + version (Task 2) ✓; tablet-only scope, no model/migration ✓.
- **Placeholder scan:** no TBD/TODO; every code step has complete code; `<clone>` in Task 4 is an explicit env parameter.
- **Type/name consistency:** `signature_data_uri` (optional, default None) consistent across controller + JS; payload keys `user_has_plating_signature` / `user_plating_signature` consistent between controller (Task 1), workspace `this.state.data.*` (Task 3); `FpSignatureConfirm` props (`signatureUrl`, `onConfirm`, `onRedraw`) consistent between the component (Task 2) and its caller (Task 3); `_commitSignOff` / `_openSignaturePad` defined and used in Task 3.

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,192 @@
# Shop-Floor Sign-Off: Reuse the Saved Plating Signature
**Date:** 2026-06-04
**Module(s):** `fusion_plating_shopfloor` (frontend + controller), reads `res.users.x_fc_signature_image` (defined in `fusion_plating_jobs`)
**Author:** Gurpreet (Nexa Systems Inc.)
**Status:** Draft — pending user review of this spec
## Summary
On the shop-floor Job Workspace, finishing any recipe step with
`requires_signoff=True` pops a draw-pad and makes the operator **draw a
signature from scratch every time**. Worse, that per-step drawing is
saved as an `ir.attachment` on the step and then **never used** — the WO
Detail / CoC reports render the signer's **Plating Signature**
(`res.users.x_fc_signature_image`, per CLAUDE.md rule 14b), not the step
attachment.
This change makes sign-off reuse the operator's saved **Plating
Signature**: if they have one, finishing is a one-tap confirm (preview +
"Sign & Finish"); if they don't, they draw once and it is **persisted to
their Plating Signature**, so every later sign-off — and every report —
uses it without redrawing.
## Current behaviour (the bug)
- `onFinishStep` ([job_workspace.js:364](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js)) — when `step.requires_signoff`, always opens `FpSignaturePad`; on submit POSTs the drawing to `/fp/workspace/sign_off`.
- `/fp/workspace/sign_off` ([workspace_controller.py:451](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)) — requires a non-empty `signature_data_uri`, creates a per-step `ir.attachment` from it, then calls `step.button_finish()` (which sets `signoff_user_id` via `_fp_autosign_if_required`).
- Reports read `signer_user.x_fc_signature_image`, **not** the step attachment → the drawing is wasted.
- `x_fc_signature_image` = `fields.Binary(string='Plating Signature', attachment=True)` on `res.users` (defined in `fusion_plating_jobs/models/res_users.py`), already in `SELF_READABLE_FIELDS` **and** `SELF_WRITEABLE_FIELDS` (fusion_plating/models/res_users.py) — so a tablet tech can read and write **their own** signature with no sudo.
## Locked decisions (from brainstorming, 2026-06-04)
| Q | Decision |
|---|----------|
| Finish UX when the user HAS a saved signature | **Quick confirm with preview** — small dialog showing their saved signature + "Sign & Finish", plus a "Use a different signature" link. One tap, no drawing. |
| Finish UX when the user has NO saved signature | Existing draw-pad → on submit, **persist the drawing to their Plating Signature** + finish. |
| "Use a different signature" | Opens the draw-pad; the new drawing **replaces** their saved Plating Signature (it is their signature) and signs this step. |
| Per-step signature `ir.attachment` | **Dropped** — redundant (reports never read it). Audit of *who signed when* stays on `signoff_user_id` + the finish timestamp. |
| Scope | **Tablet Job Workspace only.** The backend job-form `action_signoff` already works off `x_fc_signature_image` implicitly (no draw UI) — unchanged. |
## Goals / non-goals
**Goals**
- A user with a saved Plating Signature never redraws — one-tap confirm.
- A user without one draws exactly once; it persists to their Plating Signature.
- The signature shown on certs/WO reports is the same saved Plating Signature (already true; this guarantees it exists).
**Non-goals**
- Changing the backend `action_signoff` / job-form flow.
- Per-signoff historical signature snapshots (reports already read the *live* `x_fc_signature_image`; not changing that).
- Touching the signoff gate logic (`requires_signoff`, `_fp_autosign_if_required`, `_fp_check_signoff_complete`) — unchanged.
- QC-checklist or any non-workspace signature surface (none use `FpSignaturePad`).
## Architecture
### 1. Workspace load payload — expose the saved signature
In the `/fp/workspace/load` payload builder (`workspace_controller.py`),
add two keys derived from the current user (`request.env.user`, already
the per-tech session):
```python
user = request.env.user
sig = user.x_fc_signature_image # base64 or False (SELF_READABLE)
payload['user_has_plating_signature'] = bool(sig)
payload['user_plating_signature'] = (
('data:image/png;base64,%s' % sig.decode()) if sig else ''
)
```
(`x_fc_signature_image` is a small PNG; one data URI per load is fine. If
it ever grows, switch to a `/web/image/res.users/<uid>/x_fc_signature_image`
URL — deferred.)
### 2. Frontend — confirm-vs-draw in `onFinishStep`
`job_workspace.js`, `onFinishStep(step)` — replace the unconditional
`FpSignaturePad` branch with:
```js
if (step.requires_signoff) {
if (this.state.data.user_has_plating_signature) {
this.dialog.add(FpSignatureConfirm, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
signatureUrl: this.state.data.user_plating_signature,
onConfirm: () => this._commitSignOff(step, null), // no drawing -> use saved
onRedraw: () => this._openSignaturePad(step), // draw -> replaces saved
});
} else {
this._openSignaturePad(step); // first time -> draw + persist
}
return;
}
await this._callFinishStep(step, false); // plain finish (unchanged)
```
New helpers:
- `_openSignaturePad(step)` — opens the existing `FpSignaturePad`; its `onSubmit(dataUri)` calls `this._commitSignOff(step, dataUri)`.
- `_commitSignOff(step, dataUri)` — POSTs `{ step_id, signature_data_uri: dataUri /* may be null */ }` to `/fp/workspace/sign_off`, handles ok/error notifications + `refresh()` (the existing logic, factored out of the current inline `onSubmit`).
### 3. New OWL component — `FpSignatureConfirm`
`fusion_plating_shopfloor/static/src/js/components/signature_confirm.js`
(+ `signature_confirm.xml`, reuse `_signature_pad.scss` tokens or add a
small `_signature_confirm.scss`). A `Dialog` showing:
- the saved signature image (`<img t-att-src="props.signatureUrl"/>`),
- the context label,
- **Sign & Finish** → `props.onConfirm(); props.close();`
- **Use a different signature** → `props.onRedraw(); props.close();`
- **Cancel** → `props.close();`
Props: `close, title?, contextLabel?, signatureUrl, onConfirm, onRedraw`.
Mirrors `FpSignaturePad`'s shape. Register it in `JobWorkspace.components`
and the manifest assets.
### 4. Backend — `/fp/workspace/sign_off` persists, drops the attachment
`workspace_controller.py`, `sign_off(self, step_id, signature_data_uri=None)`:
```python
env = request.env
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'}
sig = (signature_data_uri or '').strip()
user = env.user
if sig:
# A drawing was supplied (first-time, or "use a different signature").
if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1]
try:
user.write({'x_fc_signature_image': sig}) # SELF_WRITEABLE; own record
except Exception:
_logger.exception("sign_off: persisting Plating Signature failed for uid %s", env.uid)
return {'ok': False, 'error': 'Failed to save your signature.'}
elif not user.x_fc_signature_image:
# No drawing AND no saved signature — nothing to sign with.
return {'ok': False, 'error': 'A signature is required. Draw one to continue.'}
try:
step.button_finish() # sets signoff_user_id + gates
except Exception as exc:
_logger.exception("sign_off: button_finish failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'step_id': step.id, 'state': step.state}
```
- `signature_data_uri` is now **optional** (defaults `None`).
- No `ir.attachment` is created (the dropped per-step artifact).
- The signature persists to the user's own `x_fc_signature_image` (direct write — the field is in `SELF_WRITEABLE_FIELDS`).
## Files touched
| # | File | Change |
|---|------|--------|
| 1 | `fusion_plating_shopfloor/controllers/workspace_controller.py` | `sign_off`: optional `signature_data_uri`, persist to `x_fc_signature_image`, drop attachment; add `user_has_plating_signature` + `user_plating_signature` to the load payload. |
| 2 | `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | NEW confirm dialog. |
| 3 | `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | NEW template. |
| 4 | `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | NEW (small). |
| 5 | `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `onFinishStep` branch; `_openSignaturePad` + `_commitSignOff` helpers; register `FpSignatureConfirm`. |
| 6 | `fusion_plating_shopfloor/__manifest__.py` | add the 3 new asset files + version bump. |
No model, view, ACL, or migration changes. `res.users.x_fc_signature_image` already exists with the right SELF_* access.
## Edge cases
| Case | Behaviour |
|------|-----------|
| Has saved sig → "Sign & Finish" | No drawing sent; `button_finish()` only; report uses saved sig. |
| No saved sig → draw | Drawing persists to `x_fc_signature_image`; future steps are one-tap. |
| Has saved sig → "Use a different signature" → draw | New drawing **replaces** saved sig + signs. |
| Empty draw | `FpSignaturePad.onSubmit` already no-ops without ink; backend also rejects empty+no-saved. |
| `button_finish` raises a gate error (required inputs, predecessor, etc.) | Returned as `{ok:false, error}` and shown as a notification — the signature has already persisted (harmless; it's their signature either way). |
| Manager/Owner with no saved sig | Same flow — draws once, persists. |
## Testing
`fusion_plating_shopfloor` can't install on local Community; verify on an
entech clone (`-u` + odoo-shell), like the WO-grouping deploy.
- **Unit (controller logic, runnable where the module installs):** `sign_off` with a data URI writes `env.user.x_fc_signature_image` and finishes; `sign_off` with no URI + an existing saved sig finishes without writing; `sign_off` with no URI + no saved sig returns the "signature required" error; no `ir.attachment` is created in any path.
- **Payload:** `/fp/workspace/load` returns `user_has_plating_signature=False` + empty `user_plating_signature` for a user with no sig, and `True` + a `data:image/png;base64,…` URI once set.
- **Live smoke (entech clone):** a tech with no Plating Signature draws on a sign-off step → their `x_fc_signature_image` is populated; the next sign-off shows the confirm-preview (no pad); the WO Detail report renders the saved signature.
## Static-check note
`node --check` rejects ESM `import` on a `.js`; copy the OWL files to
`/tmp/x.mjs` for a syntax check, and lxml/ET-parse the `.xml` template
(per the project's static-check conventions).

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 — 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.1.6',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

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

@@ -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

@@ -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

@@ -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

@@ -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.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """
@@ -79,6 +79,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
# Confirm-with-preview dialog (reuse saved Plating Signature on sign-off)
'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js',
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
@@ -109,6 +113,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 -------------------------------
@@ -225,6 +240,11 @@ class FpWorkspaceController(http.Controller):
return {
'ok': True,
'user_has_plating_signature': bool(env.user.x_fc_signature_image),
'user_plating_signature': (
('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode())
if env.user.x_fc_signature_image else ''
),
'job': {
'id': job.id,
'name': job.name,
@@ -288,6 +308,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.
}
# ======================================================================
@@ -430,37 +453,35 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/sign_off — capture signature + finish step atomically
# ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri):
def sign_off(self, step_id, signature_data_uri=None):
env = request.env
sig = (signature_data_uri or '').strip()
if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
return {
'ok': False,
'error': 'A signature is required to finish this step.',
}
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'}
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it)
if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1]
try:
env['ir.attachment'].create({
'name': f'signature_{step.id}.png',
'datas': sig,
'res_model': 'fp.job.step',
'res_id': step.id,
'mimetype': 'image/png',
})
except Exception:
_logger.exception(
"workspace/sign_off: attachment failed for step %s", step.id,
)
return {'ok': False, 'error': 'Failed to save signature.'}
sig = (signature_data_uri or '').strip()
user = env.user
if sig:
# A drawing was supplied (first-time, or "use a different
# signature"). Persist it as the user's Plating Signature so
# every future sign-off + report reuses it. x_fc_signature_image
# is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed.
if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1]
try:
user.write({'x_fc_signature_image': sig})
except Exception:
_logger.exception(
"workspace/sign_off: persisting Plating Signature failed for uid %s",
env.uid,
)
return {'ok': False, 'error': 'Failed to save your signature.'}
elif not user.x_fc_signature_image:
# No drawing AND no saved signature — nothing to sign with.
return {
'ok': False,
'error': 'A signature is required. Draw one to continue.',
}
try:
step.button_finish()
@@ -469,11 +490,7 @@ class FpWorkspaceController(http.Controller):
return {'ok': False, 'error': str(exc)}
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
return {
'ok': True,
'step_id': step.id,
'state': step.state,
}
return {'ok': True, 'step_id': step.id, 'state': step.state}
# ======================================================================
# /fp/workspace/advance_milestone — fire next_milestone_action

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

@@ -0,0 +1,35 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — SignatureConfirm
//
// Confirm dialog shown when the operator already has a saved Plating
// Signature: previews it + "Sign & Finish" (props.onConfirm) or "Use a
// different signature" (props.onRedraw, opens the draw-pad). No drawing here.
// =============================================================================
import { Component } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class FpSignatureConfirm extends Component {
static template = "fusion_plating_shopfloor.SignatureConfirm";
static components = { Dialog };
static props = {
close: Function, // dialog service injects
title: { type: String, optional: true },
contextLabel: { type: String, optional: true },
signatureUrl: { type: String }, // data: URI of saved sig
onConfirm: { type: Function }, // () => commit (no drawing)
onRedraw: { type: Function }, // () => open draw-pad
};
onConfirm() {
this.props.onConfirm();
this.props.close();
}
onRedraw() {
this.props.onRedraw();
this.props.close();
}
onCancel() {
this.props.close();
}
}

View File

@@ -25,23 +25,29 @@ import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad";
import { FpSignatureConfirm } from "./components/signature_confirm";
import { FpHoldComposer } from "./components/hold_composer";
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, FpSignatureConfirm, 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 +75,7 @@ export class FpJobWorkspace extends Component {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
target: "main",
});
return;
}
@@ -82,7 +88,7 @@ export class FpJobWorkspace extends Component {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
target: "main",
});
return;
}
@@ -164,16 +170,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 +205,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 = {
@@ -340,26 +364,20 @@ export class FpJobWorkspace extends Component {
async onFinishStep(step) {
if (step.requires_signoff) {
this.dialog.add(FpSignaturePad, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => {
try {
const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id,
signature_data_uri: dataUri,
});
if (res && res.ok) {
this.notification.add("Step signed off and finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
},
});
if (this.state.data.user_has_plating_signature) {
// One-tap confirm with a preview of the saved Plating Signature.
this.dialog.add(FpSignatureConfirm, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
signatureUrl: this.state.data.user_plating_signature,
onConfirm: () => this._commitSignOff(step, null), // use saved sig
onRedraw: () => this._openSignaturePad(step), // draw a new one
});
} else {
// First time — draw once; the backend persists it to the
// user's Plating Signature so later sign-offs are one-tap.
this._openSignaturePad(step);
}
return;
}
// Plain finish — route through /fp/workspace/finish_step which
@@ -368,6 +386,31 @@ export class FpJobWorkspace extends Component {
await this._callFinishStep(step, /* bypass */ false);
}
_openSignaturePad(step) {
this.dialog.add(FpSignaturePad, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: (dataUri) => this._commitSignOff(step, dataUri),
});
}
async _commitSignOff(step, dataUri) {
try {
const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id,
signature_data_uri: dataUri, // null -> backend uses the saved signature
});
if (res && res.ok) {
this.notification.add("Step signed off and finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
async _callFinishStep(step, bypassRequiredInputs) {
try {
const res = await rpc("/fp/workspace/finish_step", {
@@ -527,6 +570,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

@@ -0,0 +1,29 @@
// Confirm-with-preview dialog for shop-floor sign-off. Explicit hex per the
// project card-styling rule (don't rely on var(--bs-border-color)).
.o_fp_sig_confirm {
.o_fp_sig_ctx {
font-size: 0.85rem;
color: #555;
margin-bottom: 8px;
}
.o_fp_sig_preview {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
padding: 8px;
background-color: #ffffff;
border: 1px solid #d8dadd;
border-radius: 4px;
img {
max-width: 100%;
max-height: 160px;
}
}
.o_fp_sig_hint {
text-align: center;
margin-top: 6px;
font-size: 0.85rem;
color: #555;
}
}

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%; }
}
}

View File

@@ -328,3 +328,22 @@
&:hover { color: $lock-text; }
}
}
// ===== Responsive — phones / small screens (2026-06-02) ==============
// The lock screen is position:fixed + overflow-y:auto, so it already
// scrolls; it just needs the 5-up operator grid + chrome to step down
// on narrow screens. Ordered descending max-width so the smaller query
// wins the cascade.
@media (max-width: 900px) {
.o_fp_lock_tiles { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 600px) {
.o_fp_tablet_lock { padding: 16px 12px; gap: 16px; }
.o_fp_lock_tiles { grid-template-columns: repeat(3, 1fr); gap: 10px; }
.o_fp_lock_clock { font-size: 34px; }
.o_fp_lock_logo_frame { width: 220px; height: 92px; }
.o_fp_lock_wizard { padding: 24px 20px; }
}
@media (max-width: 380px) {
.o_fp_lock_tiles { grid-template-columns: repeat(2, 1fr); }
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<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 — split across racks</span>
<span t-att-class="'o_fp_rkp_unassigned' + (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_rk">
Rack <t t-esc="load_index + 1"/><t t-if="load.rack_name">: <t t-esc="load.rack_name"/></t>
</span>
<input type="number" inputmode="numeric"
class="form-control o_fp_rkp_qty"
t-att-value="load.qty"
t-att-disabled="load.moved ? 'disabled' : false"
t-on-change="(ev) => this.setQty(load, ev)"/>
<span t-if="load.rack_capacity" class="o_fp_rkp_cap">
/ <t t-esc="load.rack_capacity"/>
</span>
<button class="btn btn-sm btn-light o_fp_rkp_x"
t-att-disabled="load.moved ? 'disabled' : false"
t-on-click="() => this.removeRack(load)"></button>
</div>
</t>
<div class="o_fp_rkp_actions">
<button class="btn btn-primary" t-att-disabled="state.busy ? 'disabled' : false"
t-on-click="addRack">+ Add Rack</button>
<button class="btn btn-light" t-att-disabled="state.busy ? 'disabled' : false"
t-on-click="divideEqually">Divide Equally</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
<Dialog title="props.title or 'Confirm signature'" size="'md'">
<div class="o_fp_sig_confirm">
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
<t t-esc="props.contextLabel"/>
</div>
<div class="o_fp_sig_preview">
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
</div>
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
<button class="btn btn-primary" t-on-click="onConfirm">Sign &amp; Finish</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -100,11 +100,25 @@
<t t-if="rcv.state === 'draft'">
<div class="o_fp_ws_rcv_field">
<label>Boxes received</label>
<input type="number"
class="form-control o_fp_ws_rcv_box_input"
inputmode="numeric"
t-att-value="rcv.box_count_in || ''"
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
<div class="o_fp_ws_stepper">
<button type="button"
class="o_fp_ws_stepper_btn"
aria-label="Decrease boxes received"
t-on-click="() => this.onReceivingBoxStep(rcv, -1)">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="form-control o_fp_ws_rcv_box_input"
inputmode="numeric"
t-att-value="rcv.box_count_in || ''"
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
<button type="button"
class="o_fp_ws_stepper_btn"
aria-label="Increase boxes received"
t-on-click="() => this.onReceivingBoxStep(rcv, 1)">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_lines">
@@ -327,6 +341,8 @@
<!-- NON-TERMINAL: read-ahead detail (chips + instructions + opt-out + GateViz) -->
<t t-if="!['done', 'skipped', 'cancelled'].includes(step.state)">
<div class="o_fp_ws_step_detail">
<!-- Multi-rack split — embedded in the Racking step's row. -->
<RackingPanel t-if="step.is_racking" jobId="state.jobId"/>
<!-- Recipe chips: visible on every non-done step so operator reads ahead -->
<div class="o_fp_ws_step_chips"
t-if="step.thickness_target or step.dwell_time_minutes or step.bake_setpoint_temp or step.requires_signoff or step.requires_rack_assignment">
@@ -353,6 +369,32 @@
<t t-esc="step.instructions"/>
</div>
<!-- Masking reference(s) — attached at order entry; tap to enlarge -->
<div t-if="step.masking_refs and step.masking_refs.length"
class="o_fp_ws_mask_refs">
<div class="o_fp_ws_mask_refs_label">
<i class="fa fa-paint-brush"/>
Masking reference<t t-if="step.masking_refs.length > 1">s</t> — tap to enlarge
</div>
<div class="o_fp_ws_mask_refs_grid">
<t t-foreach="step.masking_refs" t-as="ref" t-key="ref.id">
<div class="o_fp_ws_mask_ref"
t-on-click="() => this.openMaskRef(step, ref)"
t-att-title="ref.name">
<img t-if="ref.is_image"
class="o_fp_ws_mask_ref_thumb"
t-att-src="'/web/image/' + ref.id + '/200x200'"
t-att-alt="ref.name"/>
<div t-else="" class="o_fp_ws_mask_ref_pdf">
<i class="fa fa-file-pdf-o"/>
<span class="o_fp_ws_mask_ref_pdfname" t-esc="ref.name"/>
</div>
<div class="o_fp_ws_mask_ref_zoom"><i class="fa fa-search-plus"/></div>
</div>
</t>
</div>
</div>
<!-- Opt-out notice -->
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
<i class="fa fa-ban"/> Skipped per recipe override for this WO

View File

@@ -110,6 +110,10 @@ class TestWorkspaceSignOff(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
# The HTTP request runs as the authenticated "admin" (base.user_admin);
# the controller reads/writes THAT user's x_fc_signature_image, so the
# test must set/read it on the same user (NOT self.env.user / uid 1).
self.admin = self.env.ref('base.user_admin')
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
self.job = self.env['fp.job'].create({
@@ -118,14 +122,24 @@ class TestWorkspaceSignOff(HttpCase):
'product_id': self.product.id,
'qty': 1,
})
# button_finish requires a recipe link (S21 gate). A minimal step node
# (no inputs, no sign-off) makes the gates pass so the step can finish.
kind = self.env['fp.step.kind'].search([], limit=1)
node_vals = {'name': 'ENP Plate', 'node_type': 'step'}
if kind:
node_vals['kind_id'] = kind.id
self.node = self.env['fusion.plating.process.node'].create(node_vals)
self.step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'ENP Plate',
'sequence': 50,
'state': 'in_progress',
'recipe_node_id': self.node.id,
})
def test_sign_off_rejects_empty_signature(self):
# Empty drawing AND no saved Plating Signature -> reject.
self.admin.x_fc_signature_image = False
res = _rpc(
self, '/fp/workspace/sign_off',
step_id=self.step.id, signature_data_uri='',
@@ -142,6 +156,46 @@ class TestWorkspaceSignOff(HttpCase):
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
def test_load_exposes_plating_signature_flags(self):
self.admin.x_fc_signature_image = False
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
self.assertFalse(res['user_has_plating_signature'])
self.assertEqual(res['user_plating_signature'], '')
self.admin.x_fc_signature_image = _TINY_PNG_B64
res2 = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
self.assertTrue(res2['user_has_plating_signature'])
self.assertTrue(
res2['user_plating_signature'].startswith('data:image/png;base64,'))
def test_sign_off_with_drawing_persists_signature_and_drops_attachment(self):
# First-time draw: persists to the admin's Plating Signature, finishes
# the (in_progress) step, and creates NO per-step signature attachment.
self.admin.x_fc_signature_image = False
data_uri = 'data:image/png;base64,' + _TINY_PNG_B64
res = _rpc(
self, '/fp/workspace/sign_off',
step_id=self.step.id, signature_data_uri=data_uri,
)
self.assertTrue(res['ok'])
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
self.admin.invalidate_recordset(['x_fc_signature_image'])
self.assertTrue(
self.admin.x_fc_signature_image,
'drawing persisted to the Plating Signature')
n = self.env['ir.attachment'].search_count([
('res_model', '=', 'fp.job.step'), ('res_id', '=', self.step.id)])
self.assertEqual(n, 0, 'no per-step signature attachment is created')
def test_sign_off_uses_saved_signature_without_drawing(self):
# Admin already has a saved signature -> finishing without a drawing
# still works (no signature_data_uri sent).
self.admin.x_fc_signature_image = _TINY_PNG_B64
res = _rpc(self, '/fp/workspace/sign_off', step_id=self.step.id)
self.assertTrue(res['ok'])
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceAdvanceMilestone(HttpCase):

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.8.0',
'version': '19.0.2.10.1',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -64,12 +64,14 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/visit_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml',
'views/visit_views.xml',
# Portal Templates
'views/portal_templates.xml',
'views/portal_assessment_express.xml',
@@ -79,6 +81,7 @@ This module provides external portal access for:
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_page11_sign_templates.xml',
'views/portal_visit.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -458,6 +458,7 @@ class AssessmentPortal(CustomerPortal):
'current_page': 1,
'total_pages': 2,
'assessment': None,
'visit_id': kw.get('visit_id', ''),
'google_maps_api_key': google_maps_api_key,
}
@@ -516,6 +517,7 @@ class AssessmentPortal(CustomerPortal):
'partner': partner,
'user': user,
'assessment': assessment,
'visit_id': kw.get('visit_id') or (assessment.visit_id.id if assessment.visit_id else ''),
'authorizers': authorizers,
'authorizers_json': authorizers_json,
'clients': clients,
@@ -630,6 +632,30 @@ class AssessmentPortal(CustomerPortal):
except Exception as e:
_logger.error(f"Error saving Page 11 signature: {e}")
# ===== Visit-linked: defer SO creation to visit completion =====
# Started from a visit workspace: do NOT complete into a standalone
# sale order. Leave it as a draft linked to the visit so
# visit.action_complete_visit() groups the visit's ADP devices
# (combination-checked) into ONE ADP order. The Page 11 signature is
# already saved above; pre-generate its PDF so it is ready.
if assessment.visit_id and action == 'submit':
if assessment.signature_page_11 and assessment.consent_declaration_accepted:
try:
pdf_bytes = assessment.generate_template_pdf('Page 11')
if pdf_bytes:
import base64 as b64
assessment.write({
'signed_page_11_pdf': b64.b64encode(pdf_bytes),
'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
except Exception as pdf_e:
_logger.warning(f"Visit-linked Page 11 PDF generation failed (non-blocking): {pdf_e}")
_logger.info(
f"Express assessment {assessment.reference} saved to visit "
f"{assessment.visit_id.name} (completion deferred to visit)"
)
return request.redirect(f'/my/visit/{assessment.visit_id.id}')
# Handle navigation
if action == 'submit':
# If already completed, we just saved consent/signature above -- redirect with success
@@ -803,6 +829,13 @@ class AssessmentPortal(CustomerPortal):
def _build_express_assessment_vals(self, kw):
"""Build values dict from express form POST data"""
vals = {}
# Visit linkage (assessment started from a visit workspace)
if kw.get('visit_id'):
try:
vals['visit_id'] = int(kw.get('visit_id'))
except (ValueError, TypeError):
pass
# Equipment type
if kw.get('equipment_type'):
@@ -815,7 +848,15 @@ class AssessmentPortal(CustomerPortal):
vals['wheelchair_type'] = kw.get('wheelchair_type')
if kw.get('powerchair_type'):
vals['powerchair_type'] = kw.get('powerchair_type')
if kw.get('scooter_type'):
vals['scooter_type'] = kw.get('scooter_type')
if kw.get('scooter_max_range'):
vals['scooter_max_range'] = float(kw.get('scooter_max_range') or 0)
if kw.get('x_fc_power_home_accessible'):
vals['x_fc_power_home_accessible'] = kw.get('x_fc_power_home_accessible')
if kw.get('x_fc_power_home_access_notes'):
vals['x_fc_power_home_access_notes'] = kw.get('x_fc_power_home_access_notes')
# Float measurements
float_fields = [
'rollator_handle_height', 'rollator_seat_height',

View File

@@ -2479,6 +2479,56 @@ class AuthorizerPortal(CustomerPortal):
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
return request.render(template, values)
# ==========================================================================
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
# ==========================================================================
@http.route('/my/visit/new', type='http', auth='user', website=True)
def visit_new(self, **kw):
"""Start a new assessment visit and open its workspace."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return request.redirect('/my')
visit = request.env['fusion.assessment.visit'].sudo().create({
'sales_rep_id': request.env.user.id,
})
return request.redirect('/my/visit/%s' % visit.id)
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
def visit_workspace(self, visit_id, **kw):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
return request.render('fusion_portal.portal_visit_workspace', {
'visit': visit,
'page_name': 'visit',
'error': kw.get('error'),
})
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_save_client(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if visit.exists():
visit.write({
'client_name': (post.get('client_name') or '').strip(),
'client_phone': (post.get('client_phone') or '').strip(),
'client_email': (post.get('client_email') or '').strip(),
'client_address': (post.get('client_address') or '').strip(),
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
})
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_complete(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
try:
visit.action_complete_visit()
except Exception as e:
_logger.warning("Visit %s completion failed: %s", visit_id, e)
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
def accessibility_assessment_save(self, **post):
"""Save an accessibility assessment and optionally create a Sale Order"""
@@ -2547,6 +2597,13 @@ class AuthorizerPortal(CustomerPortal):
if partner.is_authorizer:
vals['authorizer_id'] = partner.id
# Link to a visit if this form was launched from the workspace.
if post.get('visit_id'):
try:
vals['visit_id'] = int(post.get('visit_id'))
except (TypeError, ValueError):
pass
# Create the assessment
assessment = Assessment.create(vals)
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
@@ -2572,8 +2629,12 @@ class AuthorizerPortal(CustomerPortal):
if video_data:
self._attach_accessibility_video(assessment, video_data, video_filename)
# Complete the assessment and create Sale Order if requested
# Complete the assessment and create Sale Order if requested.
# When launched from a visit, always save as a draft linked to the
# visit — the VISIT completion creates the grouped sale order(s).
create_sale_order = post.get('create_sale_order', True)
if vals.get('visit_id'):
create_sale_order = False
if create_sale_order:
sale_order = assessment.action_complete()
return {
@@ -2586,12 +2647,13 @@ class AuthorizerPortal(CustomerPortal):
'redirect_url': f'/my/sales/case/{sale_order.id}',
}
else:
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
return {
'success': True,
'assessment_id': assessment.id,
'assessment_ref': assessment.reference,
'message': f'Assessment {assessment.reference} saved as draft.',
'redirect_url': '/my/accessibility/list',
'message': f'Assessment {assessment.reference} saved.',
'redirect_url': redirect_url,
}
except Exception as e:

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Reference sequence for assessment visits (VISIT/2026/0001) -->
<record id="seq_fusion_assessment_visit" model="ir.sequence">
<field name="name">Assessment Visit</field>
<field name="code">fusion.assessment.visit</field>
<field name="prefix">VISIT/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -7,5 +7,6 @@ from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import visit
from . import loaner_checkout
from . import pdf_template

View File

@@ -157,7 +157,14 @@ class FusionAccessibilityAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this accessibility assessment belongs to.',
)
# Dates
assessment_date = fields.Date(
string='Assessment Date',

View File

@@ -45,6 +45,7 @@ class FusionAssessment(models.Model):
('rollator', 'Rollator'),
('wheelchair', 'Wheelchair'),
('powerchair', 'Powerchair'),
('scooter', 'Mobility Scooter'),
], string='Equipment Type', tracking=True, index=True)
# Rollator Types
@@ -69,6 +70,31 @@ class FusionAssessment(models.Model):
('type_2', 'Adult Power Base Type 2'),
('type_3', 'Adult Power Base Type 3'),
], string='Powerchair Type')
# ===== MOBILITY SCOOTER (ADP) — 2026-06 Phase 2 =====
scooter_type = fields.Selection([
('travel_3', '3-Wheel Travel/Portable'),
('travel_4', '4-Wheel Travel/Portable'),
('standard_3', '3-Wheel Standard'),
('standard_4', '4-Wheel Standard'),
('heavy_duty', 'Heavy-Duty / Bariatric'),
], string='Scooter Type')
scooter_max_range = fields.Float(
string='Maximum Range Needed (km)', digits=(10, 1),
help='Maximum distance the client needs the scooter to travel on a charge.',
)
# ===== POWER-MOBILITY HOME ACCESSIBILITY (ADP hard rule) =====
# Applies to scooter + power wheelchair: ADP funds power mobility only if the
# device can enter and be used at the residence independently, without lifting.
x_fc_power_home_accessible = fields.Selection([
('yes', 'Yes — usable inside and outside independently'),
('no', 'No — home needs accessibility work'),
], string='Home accessible for power-mobility device?',
help='ADP will not fund a scooter / power wheelchair if the home cannot '
'take it (device left outside or in the garage). If No, the home '
'needs an accessibility product (ramp / porch lift).')
x_fc_power_home_access_notes = fields.Text(string='Home Access Notes')
# ===== EXPRESS FORM: ROLLATOR MEASUREMENTS =====
rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2))
@@ -425,7 +451,15 @@ class FusionAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this ADP assessment belongs to (groups multiple '
'assessments / funding workflows from one visit).',
)
# ===== COMPUTED FIELDS =====
document_count = fields.Integer(
string='Document Count',
@@ -1452,20 +1486,18 @@ class FusionAssessment(models.Model):
})
def _send_completion_notifications(self):
"""Send email notifications when assessment is completed"""
"""Notify the CLIENT that the assessment is complete.
The authorizer, sales rep and office are already emailed (with the full
assessment report) by ``_send_assessment_completed_email`` inside
``_create_draft_sale_order``. This method used to ALSO send the
authorizer a second, template-only email — that duplicate is removed;
here we only notify the client.
"""
self.ensure_one()
# Send to authorizer
if self.authorizer_id and self.authorizer_id.email:
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}")
except Exception as e:
_logger.error(f"Failed to send authorizer notification: {e}")
# Send to client
# Send to client (authorizer/rep/office already emailed by
# _send_assessment_completed_email in _create_draft_sale_order)
if self.client_email:
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False)

View File

@@ -53,6 +53,17 @@ class SaleOrder(models.Model):
'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, '
'or tub cutout visits.',
)
# Link to the assessment visit (one visit -> one SO per funding workflow).
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
readonly=True,
index=True,
help='The home visit this sale order was generated from. A visit '
'produces one sale order per funding workflow (ADP / MOD / ODSP / '
'private / ...).',
)
# Authorizer helper field (consolidates multiple possible fields)
portal_authorizer_id = fields.Many2one(

View File

@@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
"""Assessment Visit — bundles the assessments done during one home visit.
A sales rep + occupational therapist visit a client for 30-45 min and may do
several assessments (an ADP wheelchair plus accessibility products like a stair
lift, ramp, or tub cutout). The Visit is the hub that holds the client/context
ONCE and, on completion, groups its assessments by FUNDING WORKFLOW
(x_fc_sale_type) and creates ONE draft sale order per workflow — never one
combined SO, and never a separate SO per item within the same funding.
See docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md.
"""
import logging
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Accessibility funding source -> sale.order x_fc_sale_type. Mirrors
# fusion.accessibility.assessment._create_draft_sale_order so a grouped Visit
# routes exactly the way a single accessibility assessment would.
ACCESSIBILITY_SALE_TYPE_MAP = {
'march_of_dimes': 'march_of_dimes',
'odsp': 'odsp',
'wsib': 'wsib',
'hardship': 'hardship',
'insurance': 'insurance',
'direct_private': 'direct_private',
'other': 'other',
}
class FusionAssessmentVisit(models.Model):
_name = 'fusion.assessment.visit'
_description = 'Assessment Visit'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'visit_date desc, id desc'
name = fields.Char(
string='Visit Reference', required=True, readonly=True,
copy=False, default=lambda self: _('New'),
)
state = fields.Selection(
selection=[
('measuring', 'Measuring'),
('client_pending', 'Client Details Pending'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
],
default='measuring', tracking=True, copy=False,
)
# --- Shared client + context (entered once for the whole visit) ---------
partner_id = fields.Many2one('res.partner', string='Client', tracking=True)
client_name = fields.Char(string='Client Name', tracking=True)
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
client_address = fields.Char(string='Address')
visit_date = fields.Date(
string='Visit Date', default=fields.Date.context_today, tracking=True,
)
sales_rep_id = fields.Many2one(
'res.users', string='Sales Rep',
default=lambda self: self.env.user, tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner', string='Occupational Therapist', tracking=True,
)
# --- March of Dimes funding context (informational; spec §4.1) ----------
# MOD covers up to $15,000 per person (lifetime), income-gated.
x_fc_income_under_mod_threshold = fields.Selection(
selection=[
('yes', 'Yes — under threshold (full $15k available)'),
('no', 'No — over threshold (may be denied / partial)'),
('unknown', 'Unknown'),
],
string='Income under MOD threshold?', default='unknown',
help='March of Dimes funds up to $15,000 per person (lifetime) when the '
"client's income is under that year's threshold; over it, MOD may "
'deny or partially approve. Reminder only — no automatic cap '
'enforcement in this version.',
)
# --- Assessments performed during this visit ----------------------------
adp_assessment_ids = fields.One2many(
'fusion.assessment', 'visit_id', string='ADP Assessments',
)
accessibility_assessment_ids = fields.One2many(
'fusion.accessibility.assessment', 'visit_id',
string='Accessibility Assessments',
)
# --- Sale orders produced — one per funding workflow --------------------
sale_order_ids = fields.One2many(
'sale.order', 'visit_id', string='Sale Orders',
)
assessment_count = fields.Integer(compute='_compute_counts')
sale_order_count = fields.Integer(compute='_compute_counts')
has_mod_items = fields.Boolean(compute='_compute_has_mod_items')
@api.depends('adp_assessment_ids', 'accessibility_assessment_ids', 'sale_order_ids')
def _compute_counts(self):
for visit in self:
visit.assessment_count = (
len(visit.adp_assessment_ids)
+ len(visit.accessibility_assessment_ids)
)
visit.sale_order_count = len(visit.sale_order_ids)
@api.depends('accessibility_assessment_ids.x_fc_funding_source')
def _compute_has_mod_items(self):
for visit in self:
visit.has_mod_items = any(
a.x_fc_funding_source == 'march_of_dimes'
for a in visit.accessibility_assessment_ids
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals['name'] == _('New'):
seq = self.env['ir.sequence'].next_by_code('fusion.assessment.visit')
vals['name'] = seq or _('New')
return super().create(vals_list)
# ------------------------------------------------------------------
# Completion — group assessments by funding workflow → one SO each
# ------------------------------------------------------------------
def _ensure_partner(self):
"""Resolve the client partner for the visit, reusing an assessment's
partner/_ensure_partner when one is already set."""
self.ensure_one()
if self.partner_id:
return self.partner_id
# Borrow a child assessment's partner resolution if available.
for assessment in self.accessibility_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if hasattr(assessment, '_ensure_partner'):
return assessment._ensure_partner()
for assessment in self.adp_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if self.client_name:
return self.env['res.partner'].sudo().create({
'name': self.client_name,
'phone': self.client_phone or False,
'email': self.client_email or False,
})
raise UserError(_('Set a client (or client name) on the visit first.'))
def _create_grouped_sale_order(self, partner, sale_type, accessibility_assessments):
"""Create ONE draft sale order for a set of same-funding accessibility
assessments, link them all to it, and post each one's spec to chatter.
Mirrors fusion.accessibility.assessment._create_draft_sale_order but for
a group sharing one funding workflow."""
self.ensure_one()
SaleOrder = self.env['sale.order'].sudo()
so_vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
'state': 'draft',
'origin': _('Visit %s (%s)') % (self.name, sale_type),
'x_fc_sale_type': sale_type,
'visit_id': self.id,
}
if self.authorizer_id:
so_vals['x_fc_authorizer_id'] = self.authorizer_id.id
# MOD: pre-fill the accessibility specialist from the sales rep.
if sale_type == 'march_of_dimes' and self.sales_rep_id and self.sales_rep_id.partner_id:
so_vals['x_fc_mod_accessibility_specialist_id'] = self.sales_rep_id.partner_id.id
sale_order = SaleOrder.create(so_vals)
for assessment in accessibility_assessments:
assessment.sale_order_id = sale_order.id
assessment._add_assessment_tag(sale_order)
sale_order.message_post(
body=Markup(assessment._format_assessment_html_table()),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
assessment.write({'state': 'completed'})
# One completion notification per SO (not per assessment) — mirrors the
# standalone accessibility completion's office email.
if accessibility_assessments:
try:
accessibility_assessments[0]._send_completion_email(sale_order)
except Exception as e:
_logger.warning(
"Visit %s: completion email failed for %s: %s",
self.name, sale_order.name, e,
)
_logger.info(
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
self.name, sale_type, sale_order.name, len(accessibility_assessments),
)
return sale_order
def _validate_adp_combination(self, adp_assessments):
"""Enforce ADP device-combination rules: at most one seated-mobility
device (manual wheelchair / power wheelchair / scooter), optionally one
walker/rollator, no duplicates."""
seated_types = {'wheelchair', 'powerchair', 'scooter'}
seated = [a for a in adp_assessments if a.equipment_type in seated_types]
walkers = [a for a in adp_assessments if a.equipment_type == 'rollator']
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
if len(seated) > 1:
raise UserError(_(
'An ADP order can include only one seated-mobility device '
'(manual wheelchair, power wheelchair, or scooter). This visit has: %s.'
) % ', '.join(labels.get(a.equipment_type, a.equipment_type) for a in seated))
if len(walkers) > 1:
raise UserError(_('An ADP order can include only one walker / rollator.'))
def _assessment_sale_type(self, adp_assessment):
"""Funding workflow key for an ADP equipment assessment, mirroring
fusion.assessment._create_draft_sale_order: ADP+ODSP when the client
type is an ODSP stream, plain ADP otherwise. ADP devices that share a
key are grouped onto one sale order."""
if adp_assessment.client_type in ('ods', 'acs', 'owp'):
return 'adp_odsp'
return 'adp'
def action_complete_visit(self):
"""Group the visit's accessibility assessments by funding workflow and
create one draft SO per workflow. ADP equipment assessments keep their
existing one-assessment-one-SO completion for now (ADP multi-device
grouping arrives in Phase 2)."""
self.ensure_one()
if self.state == 'done':
raise UserError(_('This visit is already completed.'))
if not (self.accessibility_assessment_ids or self.adp_assessment_ids):
raise UserError(_('Add at least one assessment before completing the visit.'))
partner = self._ensure_partner()
# Group accessibility assessments by their funding -> sale type.
by_sale_type = {}
for assessment in self.accessibility_assessment_ids:
if assessment.sale_order_id:
continue # already has an SO; don't duplicate
sale_type = ACCESSIBILITY_SALE_TYPE_MAP.get(
assessment.x_fc_funding_source, 'direct_private')
by_sale_type.setdefault(sale_type, []).append(assessment)
for sale_type, group in by_sale_type.items():
self._create_grouped_sale_order(partner, sale_type, group)
# ADP equipment assessments: one ADP order per funding type, with the
# device-combination guard, reusing the existing (prod-tested) express
# completion. The first device creates the SO; the rest attach to it.
adp_by_type = {}
for assessment in self.adp_assessment_ids:
if assessment.sale_order_id:
continue
adp_by_type.setdefault(self._assessment_sale_type(assessment), []).append(assessment)
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
for sale_type, group in adp_by_type.items():
self._validate_adp_combination(group)
# Make sure each device carries the visit's client + OT so the
# existing completion logic has what it needs.
for assessment in group:
vals = {}
if not assessment.client_name:
vals['client_name'] = self.client_name or partner.name
if not assessment.authorizer_id and self.authorizer_id:
vals['authorizer_id'] = self.authorizer_id.id
if not assessment.partner_id:
vals['partner_id'] = partner.id
if vals:
assessment.write(vals)
primary = group[0]
sale_order = primary.action_complete_express()
sale_order.write({'visit_id': self.id, 'x_fc_sale_type': sale_type})
for extra in group[1:]:
extra.write({'state': 'completed', 'sale_order_id': sale_order.id})
sale_order.message_post(
body=Markup('<p><strong>Additional ADP device on this order:</strong> %s</p>')
% labels.get(extra.equipment_type, extra.equipment_type or 'device'),
subtype_xmlid='mail.mt_note',
)
self.write({'state': 'done', 'partner_id': partner.id})
return self._action_view_sale_orders()
def _action_view_sale_orders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Sale Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('visit_id', '=', self.id)],
'context': {'create': False},
}

View File

@@ -7,6 +7,8 @@ access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,bas
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
access_fusion_assessment_visit_user,fusion.assessment.visit.user,model_fusion_assessment_visit,base.group_user,1,1,1,1
access_fusion_assessment_visit_portal,fusion.assessment.visit.portal,model_fusion_assessment_visit,base.group_portal,1,1,1,0
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_fusion_assessment_portal fusion.assessment.portal model_fusion_assessment base.group_portal 1 1 1 0
8 access_fusion_accessibility_assessment_user fusion.accessibility.assessment.user model_fusion_accessibility_assessment base.group_user 1 1 1 1
9 access_fusion_accessibility_assessment_portal fusion.accessibility.assessment.portal model_fusion_accessibility_assessment base.group_portal 1 1 1 0
10 access_fusion_assessment_visit_user fusion.assessment.visit.user model_fusion_assessment_visit base.group_user 1 1 1 1
11 access_fusion_assessment_visit_portal fusion.assessment.visit.portal model_fusion_assessment_visit base.group_portal 1 1 1 0
12 access_fusion_pdf_template_user fusion.pdf.template.user model_fusion_pdf_template base.group_user 1 1 1 1
13 access_fusion_pdf_template_preview_user fusion.pdf.template.preview.user model_fusion_pdf_template_preview base.group_user 1 1 1 1
14 access_fusion_pdf_template_field_user fusion.pdf.template.field.user model_fusion_pdf_template_field base.group_user 1 1 1 1

View File

@@ -448,6 +448,42 @@
font-size: 1.25rem;
}
/* Start a Visit Card on Portal Home (distinct blue->indigo so it differs from
the green New Assessment tile). Mirrors .portal-new-assessment-card. */
.portal-visit-card {
background: linear-gradient(135deg, #2e7aad 0%, #4338ca 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-visit-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(67, 56, 202, 0.3) !important;
}
.portal-visit-card .card-body {
background: transparent !important;
}
.portal-visit-card h5,
.portal-visit-card small {
color: #fff !important;
}
.portal-visit-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-visit-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Authorizer Portal Card on Portal Home */
.portal-authorizer-card {
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important;

View File

@@ -388,6 +388,7 @@
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
</div>
</div>
<input type="hidden" name="visit_id" id="acc_visit_id"/>
</div>
</div>
</template>
@@ -647,6 +648,15 @@
// Fallback if Google Maps not loaded
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
// Carry visit_id from the workspace launch (?visit_id=) into the form
(function() {
var _vid = new URLSearchParams(window.location.search).get('visit_id');
if (_vid) {
var f = document.getElementById('acc_visit_id');
if (f) { f.value = _vid; }
}
})();
// Form submission
function saveAssessment(createSaleOrder) {
var form = document.getElementById('accessibility_form');

View File

@@ -85,7 +85,19 @@
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/>
<input type="hidden" name="current_page" value="1"/>
<input type="hidden" name="visit_id" id="express_visit_id" t-att-value="visit_id or ''"/>
<!-- Part of an assessment visit: completing returns to the visit, which groups
this device with the rest into one funding-routed ADP order. -->
<div t-if="visit_id" class="alert alert-info d-flex align-items-start gap-2 mb-3">
<i class="fa fa-clipboard mt-1"/>
<div>
<strong>Part of an assessment visit.</strong>
Completing this device returns you to the visit — it is grouped with the
visit's other ADP devices into a single sale order when you complete the visit.
</div>
</div>
<!-- Equipment Selection Section -->
<div class="card shadow-sm">
<div class="card-body">
@@ -98,6 +110,7 @@
<option value="rollator" t-att-selected="assessment.equipment_type == 'rollator' if assessment else False">Rollator</option>
<option value="wheelchair" t-att-selected="assessment.equipment_type == 'wheelchair' if assessment else False">Wheelchair</option>
<option value="powerchair" t-att-selected="assessment.equipment_type == 'powerchair' if assessment else False">Powerchair</option>
<option value="scooter" t-att-selected="assessment.equipment_type == 'scooter' if assessment else False">Mobility Scooter</option>
</select>
</div>
@@ -688,8 +701,62 @@
<label class="form-label fw-bold">Additional Information/Customization</label>
<textarea name="additional_customization" class="form-control powerchair-field" rows="4" placeholder="Enter any additional requirements or customization notes..."><t t-esc="assessment.additional_customization if assessment else ''"/></textarea>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
<!-- ===== MOBILITY SCOOTER ===== -->
<div id="scooter_form" class="equipment-form" style="display: none;">
<h2 class="text-center fw-bold text-uppercase mb-4">Mobility Scooter Assessment</h2>
<div class="mb-4">
<label class="form-label fw-bold">Scooter Type</label>
<select name="scooter_type" class="form-select">
<option value="">-- Select Type --</option>
<option value="travel_3" t-att-selected="assessment.scooter_type == 'travel_3' if assessment else False">3-Wheel Travel/Portable</option>
<option value="travel_4" t-att-selected="assessment.scooter_type == 'travel_4' if assessment else False">4-Wheel Travel/Portable</option>
<option value="standard_3" t-att-selected="assessment.scooter_type == 'standard_3' if assessment else False">3-Wheel Standard</option>
<option value="standard_4" t-att-selected="assessment.scooter_type == 'standard_4' if assessment else False">4-Wheel Standard</option>
<option value="heavy_duty" t-att-selected="assessment.scooter_type == 'heavy_duty' if assessment else False">Heavy-Duty / Bariatric</option>
</select>
</div>
<div class="row mb-3">
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-bold">Maximum Range Needed (km)</label>
<div class="input-group">
<input type="number" step="1" name="scooter_max_range" class="form-control"
t-att-value="assessment.scooter_max_range if assessment else ''"/>
<span class="input-group-text">km</span>
</div>
</div>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
</div>
</div>
@@ -1191,9 +1258,10 @@
<div class="card shadow-sm">
<div class="card-body text-center">
<button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5">
<i class="fa fa-check me-2"/>Submit Assessment
<t t-if="visit_id"><i class="fa fa-clipboard me-2"/>Save to Visit</t>
<t t-else=""><i class="fa fa-check me-2"/>Submit Assessment</t>
</button>
<a href="/my/assessments" class="btn btn-outline-secondary btn-lg px-4 ms-3">
<a t-att-href="('/my/visit/%s' % visit_id) if visit_id else '/my/assessments'" class="btn btn-outline-secondary btn-lg px-4 ms-3">
Cancel
</a>
</div>
@@ -1278,6 +1346,7 @@
var rollatorForm = document.getElementById('rollator_form');
var wheelchairForm = document.getElementById('wheelchair_form');
var powerchairForm = document.getElementById('powerchair_form');
var scooterForm = document.getElementById('scooter_form');
var wheelchairTypeSelect = document.querySelector('select[name="wheelchair_type"]');
var reasonSelect = document.getElementById('reason_for_application');
var previousFundingContainer = document.getElementById('previous_funding_date_container');
@@ -1339,13 +1408,16 @@
disableFormInputs(rollatorForm);
disableFormInputs(wheelchairForm);
disableFormInputs(powerchairForm);
disableFormInputs(scooterForm);
if (value === 'rollator') {
enableFormInputs(rollatorForm);
} else if (value === 'wheelchair') {
enableFormInputs(wheelchairForm);
} else if (value === 'powerchair') {
enableFormInputs(powerchairForm);
} else if (value === 'scooter') {
enableFormInputs(scooterForm);
}
}

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