Compare commits

..

99 Commits

Author SHA1 Message Date
gsinghpal
80d06ff77f feat(fusion_claims): service-booking wizard live client search + address autocomplete
The wizard's dynamic fields were non-functional: the "Existing customer" box had
no search (no endpoint, no handler — the typed string was only sent on submit),
and address autocomplete never attached (google_address_autocomplete.js patches
FormController, which a client action is not).

Live client search:
- new jsonrpc endpoint /fusion_claims/service_booking/search_customers — searches
  res.partner (name/phone/email) and resolves a typed SO number to its partner.
- JS: debounced (250ms) onCustSearch -> .sb-cust-results dropdown; pickCustomer()
  sets state.partnerId + fills the contact, which action_book_from_wizard already
  consumes for cust_mode='existing'.
- FIELD-SAFE domain: res.partner has NO `mobile` field in Odoo 19 — referencing it
  raises ValueError and the swallowed exception made the search silently return
  nothing. Build the OR domain only over fields in Partner._fields. Smoke-tested on
  prod data ('25450'->1, '1 905-'->8).

Address autocomplete (wizard-local):
- component loads Google Places (key = ICP fusion_claims.google_maps_api_key, which
  IS configured on westin), attaches via useRef('root')+onMounted/onPatched to every
  input.sb-addr-input, writes street/city/lat/lng into reactive state. Fully guarded
  (per-input _sbAc, _addrStarted/_addrNoKey gate, .catch on both hooks) so a missing
  key degrades to manual entry and can never break render.

Verified: pyflakes clean, JS node --check, SCSS compiles, XML well-formed, dropdown
UI rendered against Bootstrap+compiled CSS. Documented in fusion_claims/CLAUDE.md §48.
Bump fusion_claims 19.0.9.6.0 -> 19.0.9.7.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:07:47 -04:00
gsinghpal
53fe13344d fix(fusion_claims): service-booking wizard responsive — namespace Bootstrap classes + reorder media queries
The 19.0.9.5.0 CSS pass (padding/scroll) did not fix "fields sitting on each
other" on small screens. Deep dive against the live web.assets_backend bundle
found two compounding root causes (both measured, not guessed):

1. Dead media query. The @media(max-width:560px) collapse for the inner field
   grids (.two/.three) and .timepick was nested BEFORE the base .two/.three/
   .timepick rules. Equal specificity → the later base rule wins → the media
   query never applied. matchMedia matched at 320/390px yet grids stayed 2–3
   columns and crammed/truncated. Moved all @media overrides to the END of the
   .o_service_booking block so they win the cascade.

2. Bootstrap class collision. The wizard reused row/card/grid/btn; Odoo's
   backend Bootstrap applies .row{display:flex;margin:0 -16px}, .card{display:flex},
   etc. globally even under the scoped parent (they win for properties the scoped
   rule doesn't set). Measured: every .row computed display:flex + margin-left/
   right:-16px. Renamed all layout classes to sb-* (sb-row/sb-card/sb-grid/sb-btn)
   in service_booking.xml + service_booking.scss.

Verified at 320/390/768/1280 against the real prod bundle (computed
grid-template-columns now 1fr at <=560px; .sb-row margin 0 / display block;
2-col desktop intact). Documented both gotchas in fusion_claims/CLAUDE.md §47.

Bump fusion_claims 19.0.9.5.0 -> 19.0.9.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:38:52 -04:00
gsinghpal
423f288507 feat(fusion_shipping): Shipping Label + Commercial Invoice smart buttons on delivery order
Carriers post the shipping label and (for international) the commercial invoice
to the delivery order's chatter, where they were hard to find. Add two smart
buttons on the stock.picking form that open the latest of each via the PDF
preview dialog (fusion_pdf_preview when installed, else open in a new tab).

Document discovery is carrier-agnostic (computed from the picking's attachments):
labels match 'Label*'; invoices match '*CommercialInvoice*' (UPS/Canada Post) or
'ShippingDoc-*' (FedEx/DHL, _get_delivery_doc_prefix). Buttons hide when absent.

Verified on entech: real FedEx picking resolved its label (invoice correctly
none for a domestic ship); synthetic UPS names resolved label+invoice and the
invoice button fired fusion_pdf_preview.open_attachment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:24:26 -04:00
gsinghpal
a86f20017d feat(fusion_shipping): UPS bill-receiver clarity + controllable commercial invoice
- Relabel UPS 'Bill My Account' -> "Bill recipient's UPS account" with clear
  help (it bills the customer's own UPS account; $0 shipping line; falls back to
  Bill Shipper when the customer has no account on file).
- Improve the customer 'UPS Account Number' field help (stored per-customer,
  auto-recalled at ship time for Bill Receiver).
- Add ups_rest_documentation_type setting (No / UPS commercial invoice) on the
  UPS REST carrier, mirroring FedEx. Default 'invoice' preserves the existing
  auto-generate-on-international behaviour; gate require_invoice on it so it can
  be turned off. Surfaced on the UPS REST config page.

Validated live on entech (UPS production): CA->US shipment generated the label
+ a 60KB commercial invoice PDF (country of origin auto = CA, HS code applied),
then voided. Bill Receiver request confirmed accepted by UPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:12:10 -04:00
gsinghpal
7426501555 fix(fusion_shipping): UPS REST ship request sends malformed ReferenceNumber
The UPS REST ship request wrapped the reference as
{'Value': [{'Code':'BM','Value': picking.name}]}, but _ups_rest_prepare_shipping_data
already builds reference_number as a list of {Code, Value} dicts. UPS expects
ReferenceNumber to be such an object (or array) with a STRING Value and rejects
the double-wrapped form on the ship call. This branch fires for every non-US/US
(e.g. CA->CA, CA->US) shipment, so rating worked but label creation failed.

Pass the list directly. Validated end-to-end against UPS production from a
Canadian origin: rate + real label (tracking 1Z6W...6355, then voided).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:47:05 -04:00
gsinghpal
3e787a1b24 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-06-04 18:24:34 -04:00
gsinghpal
6f006e24ad feat(certificates): cert Customer Contact is multi-contact, auto-filled, sends to all
fp.certificate.contact_partner_id (single 'Customer Contact') becomes
contact_partner_ids (Many2many) — same shape as the partner's Default CoC
Contacts, as requested.

- Auto-populate: at job creation (fp.job cert resolution) + lazy-fill at issue,
  contact_partner_ids = the customer's x_fc_default_coc_contact_ids (ALL).
- Send: action_send_to_customer pre-fills the composer with exactly the cert's
  contact_partner_ids, so the CoC goes to all the defined clients (fallback:
  company).
- Primary: the FIRST contact prints on the CoC + is gated for email; report
  uses contact_partner_ids[:1].
- Gate: requires >=1 Customer Contact + the primary has an email.
- View: many2many_tags.
- Migration 19.0.10.3.0: copies each cert's old single contact into the new M2m,
  drops the orphaned column.

Deployed + verified on entech: migration copied 16 certs, old column dropped,
field is M2m, send pre-fills the cert contacts, CoC report renders. entech-only
part_line_ids preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:09:36 -04:00
gsinghpal
ba6aeaaca9 feat(certificates): multiple Default CoC Contacts per customer (M2o -> M2m)
res.partner.x_fc_default_coc_contact_id (single Many2one) becomes
x_fc_default_coc_contact_ids (self-referential Many2many 'Default CoC
Contacts') so a customer can list several contacts who need the CoC.

- res.partner: M2m field (rel fp_default_coc_contact_rel) + many2many_tags.
- Cert: contact_partner_id (primary addressee printed on the cert) is set to
  the FIRST CoC contact at job creation + lazy-filled at issue.
- Send: action_send_to_customer pre-fills the email composer with ALL the
  customer's CoC contacts (primary + the rest), falling back to the company.
- fp.job cert-default resolution + the action_issue gate wording updated.
- Migration 19.0.10.2.0: copies each partner's old single value into the new
  M2m, then drops the orphaned column.

Deployed + verified on entech: migration copied 2 existing values, old column
dropped, field is M2m, send pre-fills all contacts. entech-only part_line_ids
/ multi-part resolver preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:31:17 -04:00
gsinghpal
27577dd51a Merge branch 'claude/service-booking-css-fix' into main
Service-booking wizard CSS: scroll on small screens (height:100% so overflow
engages), padded fields (!important vs Odoo input normalisation), narrow-screen
sub-grid collapse. Also hardens scripts/verify_service_booking.sh with an
asset-bundle compile gate. Clone-verified GREEN (assets compile) + deployed to
westin-v19 (fusion_claims 19.0.9.5.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:02:51 -04:00
gsinghpal
a10b7425f7 fix(scripts): asset-compile gate — odoo shell needs --no-http (port 8069 held by live app)
The compile gate's 'odoo shell' tried to bind 8069 (the running app holds it) and
died with 'Address already in use' before compiling, false-failing the gate. Add
--no-http --http-port=0 --gevent-port=0 (same as the test run) so the shell loads
the registry and force-compiles the bundles without binding a port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:57:38 -04:00
gsinghpal
dcd4955bb7 feat(jobs+receiving): confirm->receive flow, lock recipe, reset step, lock steps, fix bake gate
- Confirm->Receive (A): after a single interactive SO confirm, receiving's
  action_confirm returns action_view_receiving() so the user lands straight
  on the Receive Parts screen (opt-out via fp_no_receiving_redirect context).
- Lock recipe (1): recipe_id readonly on the WO form — stick to the
  order-entry recipe.
- Hide spec (2): customer_spec_id invisible on the WO form.
- Reset step (3): new fp.job.step.button_reset (operator-usable, audited) +
  an undo button next to Start. Resets to Ready, clears finish + sign-off,
  closes open timelogs, keeps start audit + move/CoC history.
- Lock steps (4): steps list create=false delete=false (no Add a line / no
  trash) — steps come from the recipe, only skippable, never deleted.
- Bake gate fix (5): _fp_missing_required_step_inputs now honours the node's
  collect_measurements master switch, matching the Record-Inputs wizard.
  collect_measurements=False + required prompts no longer blocks finish
  (wizard shows 0 rows, so the gate must too). Unblocks WO-30098 + 63 other
  affected nodes (bake steps).

Deployed + verified on entech (-u jobs; bake finishes, reset done->ready,
recipe readonly, spec hidden, steps locked, receiving redirect target OK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:55:34 -04:00
gsinghpal
a2277b481c fix(fusion_claims): service-booking wizard scrolls + responsive + padded fields
Reported on the live wizard: no scroll on small screens, not responsive, fields
look unpadded.
- .o_service_booking: min-height:100% -> height:100% so the root is capped to the
  action area and overflow:auto scrolls INTERNALLY (min-height let it grow to
  content height, so the clipping action container never scrolled).
- input/select/textarea.f: padding 10px 12px !important + line-height 1.4 so
  Odoo's backend input normalisation can't strip the field padding.
- add a <=560px media query collapsing the .two/.three sub-grids, wrapping the
  time picker, and tightening margins (the main .grid already collapses at 780px).
- bump version 19.0.9.4.0 -> 19.0.9.5.0 (asset cache-bust).

Also harden scripts/verify_service_booking.sh: force-compile web.assets_backend +
web.assets_web_dark on the clone after tests, so a broken SCSS fails the deploy
gate BEFORE prod (a bad stylesheet would break the whole backend bundle; -u does
not compile assets — Odoo compiles them lazily).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:51:44 -04:00
gsinghpal
197030a188 feat(reports): packing slip in Print menu of SO, Work Order, Receiving
Generalize the delivery packing slip template to be model-agnostic
(branches on doc._name to resolve the sale order + ship-to for
sale.order / fp.job / fp.receiving / fusion.plating.delivery) and add
three report actions bound to sale.order, fp.job and fp.receiving so the
packing slip appears in each one's Print menu (delivery already had it).
Uses _scheduled / _notes so it never AttributeErrors on models without
scheduled_date / notes. Declare the fusion_plating_receiving dep on
reports (already transitive via logistics) for the fp.receiving binding.

Verified on entech: real content for SO-30102, WO-30102, RCV-30103; all
four Print-menu bindings live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:25:52 -04:00
gsinghpal
c97a0d985c feat(reports): packing slip for local deliveries (fusion.plating.delivery)
The packing slip report only existed for stock.picking (Delivery Orders),
but this shop ships via fusion.plating.delivery and has no pickings — so
packing slips never rendered for their flow, and the prior auto-generate +
email-notification paths pointed the stock.picking report at a delivery
(wrong model -> blank PDF).

Add a delivery-native variant: report_fp_packing_slip_delivery_portrait +
action_report_fp_packing_slip_delivery_portrait (bound to
fusion.plating.delivery -> shows in the delivery Print menu), resolving the
SO + lines from the delivery job_ref (same pattern as the BoL report) and
reusing the shared styles / address / signoff bits + a sale.order.line
items table. Repoint _fp_generate_packing_slip (dispatch auto-gen) and the
notification attachment to the new report.

Verified on entech: real content (customer, PO, items, PS#) for DLV-30102 —
142KB PDF vs prior blank 12.8KB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:12:50 -04:00
gsinghpal
e6bbf566ca feat(logistics): auto-generate packing slip on local delivery dispatch
fusion.plating.delivery already had packing_list_attachment_id + a viewer
action, but nothing populated it — shipping a local delivery produced no
packing slip. Add _fp_generate_packing_slip(): renders
fusion_plating_reports.action_report_fp_packing_slip_portrait and stores
it on packing_list_attachment_id. Hooked into action_start_route (the
dispatch / loaded-on-vehicle moment, so it travels with the goods) and as
a generate-if-missing catch-all on action_mark_delivered. Idempotent
(skips deliveries that already have one unless force=True) and best-effort
(a report glitch logs + continues, never blocks shipping). Report action
resolved at runtime so logistics keeps no hard dep on
fusion_plating_reports. Deployed + verified on entech (12.8KB PDF for
DLV-30097, rolled back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:49:30 -04:00
gsinghpal
86e9fdead8 fix(jobs): guard res.partner.mobile read in delivery defaults (Odoo 19)
res.partner has no `mobile` field in this Odoo 19 build, so
_fp_resolve_delivery_defaults crashed with AttributeError when a job's
last shop step finished and auto-created a delivery
(button_finish -> _fp_check_advance_post_shop -> _fp_create_delivery).
This blocked operators from finishing the step at all.

Guard the read with the codebase's 'x' in obj._fields pattern so it
falls back to phone, and still picks up mobile on instances that define
it. Deployed + verified on entech (restart, no -u; pure Python change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:29:25 -04:00
gsinghpal
c80ffa1b2c feat(fusion_plating): internal sticker = external layout w/ internal notes + Receiving print buttons
- Internal Job Sticker is no longer a separate Layout A: it's now a COPY of the
  External sticker (Layout B, one per box, logo + WO + BOX + QR + rail fields +
  prominent PLATING THICKNESS banner) but feeds the INTERNAL description and
  labels its notes "INTERNAL NOTES" so the shop copy can't be confused with the
  customer copy. The old Layout-A body template is deleted.
- Removed the Internal sticker from the fp.job Print menu (binding_model_id ->
  False); it now prints from the Receiving screen instead.
- Added "External Sticker" + "Internal Sticker" print buttons to the fp.receiving
  form header (shown once a WO exists). Each renders one label per tracked box
  for the receiving's work order (passes a single WO so the SO-scoped box loop
  doesn't reprint each label per job).
Verified on entech (WO-30094 / RCV-30096): internal renders Layout B with the
internal description + INTERNAL NOTES; external unchanged; both receiving buttons
return the right report actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:16:14 -04:00
gsinghpal
97880765b5 docs(fusion_plating): note .article UTF-8 fix breaks the dpi=96 mm job stickers
Caveat on the existing "custom-header reports need .article for UTF-8" rule:
the dpi=96 mm-based job stickers are the exception — adding .article re-routes
through Odoo's standard report CSS and blows up the mm/dpi layout. For those,
strip the non-ASCII glyph to ASCII in _clean() instead. (Learned fixing the
'375ºF' bake-text mojibake on the external sticker.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:59:32 -04:00
gsinghpal
587988bb06 feat(fusion_plating_jobs): external sticker — prominent thickness banner + strip degree mojibake
- Relocate plating thickness out of the cramped rail (where it shared a row
  with Due and wrapped) to a big 21pt "PLATING THICKNESS" banner at the top of
  the main panel — the team's most-watched spec, now hard to miss. Gated on a
  new has_thk flag (real value with a digit; skips empty/'N/A'). Due takes the
  full rail row.
- Fix the bake-text degree mojibake: operators type 'º' (U+00BA) for "375ºF";
  through this sticker's lightweight html_container path (no .article UTF-8
  wrapper) it renders "375°F". Adding a .article wrapper fixes encoding but
  blows up the dpi=96 mm layout (tested), so _clean() now strips º/°/˚ to clean
  ASCII -> "375F".
Verified on entech (WO-30094).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:57:33 -04:00
gsinghpal
a209648ed9 fix(fusion_plating_jobs): external sticker — stop the rail clipping the Due/Thk row
The left rail (overflow:hidden, fixed height) was ~8mm over its budget, so the
last grid row (Due | Thk) fell off the bottom and rendered as an empty band.
Reclaimed the height: r-logo 11→9mm, r-wo 14→13mm, r-qrflags 36→32mm (+ qfwrap-full
33→31mm / qffull line-height 36→32mm to match), r-fld padding 1→0.7mm. Due/Thk
now render fully. Verified on entech (WO-30094, PO 980933709).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:42:58 -04:00
gsinghpal
ea6b3fe2e9 fix(fusion_plating_jobs): internal sticker row 3 — tighten label/value gap, widen Thk
- Drop the <br/> between label and value (.lbl is display:block, so the
  value already sits beneath it; the <br/> added a blank-line gap).
- Rebalance widths: PO# 34→25%, Qty 16→13%, Due 30→26%, Thk 20→36% + nowrap,
  so a thickness RANGE (e.g. 0.0025" - 0.0030") stays on ONE line instead of
  wrapping. Verified on entech (WO-30096).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:07:16 -04:00
gsinghpal
b23eaa5695 feat(fusion_plating_jobs): internal sticker — add factory logo, customer→row 2, bigger 4-field row 3
Internal Job Sticker (Layout A) redesign per operator feedback:
- Factory (ENTECH) logo added to the black header band on a white chip
  (mirrors the external sticker; visible on the dark band + thermal print).
- Customer moved out of row 3 up next to Part# in row 2. Part# keeps the
  dominant width (stripped customer name is short + consistent), MASK/BAKE
  flags still float at the Part# edge.
- Row 3 now PO# / Qty / Due / Thk (4 fields, customer removed) with bigger
  values (13/14/12/11pt) spread across the full width.
- Internal header QR trimmed 30→27mm so the QR-driven band is shorter; the
  freed height flows to the NOTES block.
Rendered + verified on entech (WO-30072 / AMP-CANA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:19:17 -04:00
gsinghpal
489312365e fix(certificates): honour recipe thickness suppression at cert issue
The recipe-cert-toggles feature (fb6cccc8) taught
fp.job._resolve_required_cert_types to suppress thickness for recipes
with requires_thickness_report=False (passivation, chemical conversion,
anodize seal-only). But the actual thickness-data ENFORCEMENT never got
the memo: both fp.certificate.action_issue's hard gate AND the
Issue-Certs wizard's readiness hint re-derived 'needs thickness' from
partner flags only and ignored the recipe. Result: a passivation CoC for
a thickness/strict customer could never be issued — the gate demanded
Fischerscope data the process physically cannot produce.

Consolidate the partner-flag + recipe-suppression logic into one
fp.certificate._fp_needs_thickness_data() helper and route both the gate
and the wizard through it, so the cert-type resolver and the issue-time
gate can never drift again. Add regression tests: passivation recipe
suppresses the issue gate even for strict-thickness customers; a normal
recipe still enforces (control, guards aerospace).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:43:05 -04:00
gsinghpal
6728197570 Update .DS_Store 2026-06-04 10:36:45 -04:00
gsinghpal
eea4dad048 Merge branch 'claude/technician-service-booking' into main
Technician Service Booking & Auto-Quote: OWL 'Book a Service' wizard,
editable fusion.service.rate rate-card table, auto draft repair Sale Order
(call-out + per-km), and the fusion_tasks datetime-inverse tz fix. Clone-verified
GREEN and deployed to westin-v19 (fusion_claims 19.0.9.4.0) on 2026-06-04.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:53:10 -04:00
gsinghpal
63694eccb1 fix(scripts): verify_service_booking — general orphan-FK sweep + test port fix + scoped tags
Hardened after the first real clone-verify on odoo-westin:
- Cleanup now generates an orphan-delete for EVERY single-column FK from PROD's
  pg_constraint and applies it to the clone (was tax-tables-only). westin-v19 also
  has deleted-company (payslip_tags_table, account_account_res_company_rel) and
  deleted-journal (account_payment_method_line) orphans that broke the clone -u.
- run_odoo passes --http-port=0 --gevent-port=0 so --test-enable (which forces
  http_spawn even with --no-http in Odoo 19) doesn't die on 'Address already in use'.
- TEST_TAGS scoped to this feature's classes (the broad tag also runs pre-existing
  dashboard/wizard tests that fail in this prod-config runner, unrelated to this work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:10:25 -04:00
gsinghpal
252716156c test(fusion_tasks): tz test task needs description (NOT NULL) + is_in_store
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:07:36 -04:00
gsinghpal
dfa266d691 test(fusion_claims,fusion_tasks): fix clone-test failures (future dates + seed-aware asserts)
Real install verified on the Westin clone; these were test-only bugs:
- Task-create tests hardcoded scheduled_date 2026-06-03, now in the past, which
  the base _check_no_overlap rejects ('Cannot schedule tasks in the past'). Use
  future dates (tz test pins a future July date so Toronto stays EDT for the
  9:00->13:00 UTC assertion).
- Service-rate resolver tests created rows with seeded codes (callout_standard_normal,
  per_km) -> UNIQUE(code) violation post-install. Assert against the seed instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:04:11 -04:00
gsinghpal
7b8364eb58 fix(fusion_claims): seed service products as product.product (direct variant ref)
The <template>_product_variant auto external-ID is not reliably created in this
Odoo 19 (only 5 exist on westin-v19; none for these products or product_labor_hourly),
so the rate rows' product_id refs failed at install: 'External ID not found:
..._product_variant'. Seed each product as model=product.product (the xmlid IS the
variant; name/price/uom/etc. delegate via _inherits) and reference it directly.
In-shop labour now uses a dedicated product_labour_inshop ($75) rather than reusing
product_labor_hourly, whose variant xmlid likewise does not exist. Caught on the
Westin clone install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:55:00 -04:00
gsinghpal
4e5e9f4c91 fix(fusion_claims): drop uom_po_id from seed labour products
product.template lost the separate purchase-UoM field uom_po_id in Odoo 19
(only uom_id remains). The plan's seed carried uom_po_id, which ParseErrors at
install: 'Invalid field uom_po_id in product.template'. Caught on first real
clone-install on the Westin Enterprise clone. The existing product_labor_hourly
uses uom_id only — match that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:46:58 -04:00
gsinghpal
f84c22c743 feat(fusion_claims): Book a Service entry point + version bump
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:31:46 -04:00
gsinghpal
46d19fd581 fix(fusion_claims): OWL wizard review fixes (statement handler, scss borders, tech guard)
- Move the call-type select handler into onCallType() — OWL cannot compile a
  multi-statement inline t-on body (was a render-breaking crash on mount).
- Replace color-mix() inside border shorthands with var(--sb-border) (Odoo-19
  SCSS drops color-mix in a border shorthand).
- Technician placeholder option value '' (not 'false') so the required-tech
  guard isn't bypassed.
- Remove dead setTiming(); null-coalesce the refdata onWillStart load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:29:55 -04:00
gsinghpal
56ca82c611 feat(fusion_claims): OWL service-booking wizard + dark/light SCSS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:16:29 -04:00
gsinghpal
d457b86eaa fix(fusion_claims): default booking description + isolate order-less task test
Review follow-up: the base fusion.technician.task.description is required=True and
non-in-store tasks require an address (_check_address_required). So:
- action_book_from_wizard now defaults description to 'Service booking' when the
  payload carries neither description nor issue (avoids a required-field failure).
- test_task_without_order_is_allowed now sets description + is_in_store=True so it
  exercises only the relaxed _check_order_link, not those unrelated base constraints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:09:14 -04:00
gsinghpal
92e8a18fcb feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:00:53 -04:00
gsinghpal
245e551c68 feat(fusion_claims): service pricing resolver + draft-SO builder from rate table
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:54:39 -04:00
gsinghpal
a022eaaabe feat(fusion_claims): allow order-less tasks + service-repair SO flag
Relaxes _check_order_link to a no-op (service bookings auto-create their SO;
in-shop/walk-in tasks may have none) and adds x_fc_is_service_repair on
sale.order. The 'Service Repair' crm.tag from the plan is intentionally
omitted: fusion_claims does not depend on crm and sale.order has no tag_ids;
the boolean flag is the repair-SO identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:51:10 -04:00
gsinghpal
0e6bb7b676 fix(fusion_tasks): make datetime inverses use the same tz resolver as compute
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:47:48 -04:00
gsinghpal
d5d410f6d0 chore(fusion_claims): bump version for service-rate foundation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:42:50 -04:00
gsinghpal
41141a75e8 feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:42:42 -04:00
gsinghpal
d512dfccf0 feat(fusion_claims): seed service-rate rows from the rate card
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:41:44 -04:00
gsinghpal
5e9576ed8f feat(fusion_claims): seed service-rate products
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:39:03 -04:00
gsinghpal
80d9a960e7 feat(fusion_claims): add fusion.service.rate model + resolvers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:38:27 -04:00
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
f0400114f9 docs(service-booking): add spec, plans, mockup, and clone-verify script
Kickoff brief, design spec, both implementation plans (rates foundation +
booking wizard), the UI mockup, and the hands-off Westin clone-verify/deploy
script for the Technician Service Booking feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:20: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
164 changed files with 10704 additions and 378 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3104,3 +3104,72 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
- Add new gotchas in the right format
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
- Know the deployment fact that fusion_portal is always co-installed
## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`)
The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`),
so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this
wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix
the real cause — verify with a render, not by eye):
1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.**
The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under
`.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex;
margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`,
`.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property
the scoped rule doesn't set). Measured live: every wizard `.row` computed
`display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields
to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are
`sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class
that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …).
2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).**
SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{
grid-template-columns:1fr } … }` was nested high in the file, *before* the base
`.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have
the same specificity, so the later base rule overrode the media query — it was **dead**,
and the inner field-grids never collapsed to one column on a phone (fields crammed 23
across). `matchMedia('(max-width:560px)')` returned true while the columns stayed 2-up —
the tell that it's a cascade-order bug, not a media-match bug. **Fix:** all responsive
`@media` overrides live at the **end** of the `.o_service_booking { … }` block.
**How it was verified (do this, don't eyeball):** pull the live compiled bundle from prod
(`env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` returns `ir.attachment`
record(s) in Odoo 19 — read `.raw`, not a string), render the wizard markup against it with
the real web-client height/scroll chain (`html,body{height:100%}` → `.o_web_client` flex
column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard's
`height:100%;overflow:auto` scrolls) at 320/390/768/1280, and read computed
`grid-template-columns` / `margin-left` / `display`. A standalone vanilla-Bootstrap repro is
**not** faithful — it rendered fine and falsely cleared the bug.
## 48. Service Booking wizard — dynamic fields (live client search + address autocomplete), v19.0.9.7.0
The wizard is a **client action** (registered OWL component), not a form view, so two
"type-ahead" features had to be built into the component itself:
1. **Live client search** ("Existing customer" box). Endpoint
`/fusion_claims/service_booking/search_customers` (jsonrpc, auth=user) searches
`res.partner` and resolves a typed SO number to its partner; the JS debounces (250 ms),
shows a `.sb-cust-results` dropdown, and `pickCustomer()` sets `state.partnerId` + fills the
contact fields. The backend (`action_book_from_wizard`) already consumes `partner_id` for
`cust_mode='existing'`, so picking links the existing contact.
**GOTCHA (cost a near-miss): `res.partner` has NO `mobile` field in Odoo 19** — a domain
leaf `('mobile','ilike',q)` raises `ValueError: Invalid field res.partner.mobile`, and
because the controller's `except` swallows it the search silently returns nothing (looks
exactly like "search not working"). Build the OR domain only over fields present in
`Partner._fields` (`name`/`phone`/`email`); same for reading `p.mobile`. Verify any new
partner-field reference against `_fields` before shipping.
2. **Address autocomplete.** `google_address_autocomplete.js` patches `FormController` only,
so it does **not** reach this client action. The component loads Google Places itself
(key from ICP `fusion_claims.google_maps_api_key`), attaches via `useRef('root')` +
`onMounted`/`onPatched` to every `input.sb-addr-input`, and writes
`street`/`city`/`lat`/`lng` straight into reactive `state` (no DOM hacks — we're inside the
component). Re-attach on patch is guarded by an `_sbAc` flag per input and an `_addrStarted`
/`_addrNoKey` gate so a missing key just degrades to manual entry and can never break render
(both lifecycle calls are `.catch(()=>{})`).
**Verifying the search without a browser:** the endpoint logic is plain ORM — smoke it in an
`odoo shell` against real data (`Partner.search(<built domain>, limit=8)`); a vanilla repro of
the `.sb-cust-results` dropdown markup + compiled CSS confirms the UI. Address autocomplete
needs the live key + Google Maps, so it can only be confirmed in a real browser session.

View File

@@ -7,6 +7,7 @@ import logging
from . import models
from . import wizard
from . import controllers
_logger = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.9.2.0',
'version': '19.0.9.7.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -98,9 +98,13 @@
'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml',
'data/product_labor_data.xml',
'data/service_rate_products.xml',
'data/service_rate_data.xml',
'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml',
'views/res_config_settings_views.xml',
'views/service_rate_views.xml',
'views/service_booking_action.xml',
'views/sale_order_views.xml',
'views/account_move_views.xml',
'views/account_journal_views.xml',
@@ -181,12 +185,20 @@
# Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
# Service Booking wizard (client action): tokens MUST load before
# the component scss so the --sb-* vars resolve.
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
'fusion_claims/static/src/js/service_booking/service_booking.js',
'fusion_claims/static/src/xml/service_booking.xml',
],
'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
],
},
'images': ['static/description/icon.png'],

View File

@@ -0,0 +1 @@
from . import service_booking

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
class ServiceBookingController(http.Controller):
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
def refdata(self, **kw):
env = request.env
Users = env['res.users']
techs = Users.search([('x_fc_is_field_staff', '=', True)]) \
if 'x_fc_is_field_staff' in Users._fields else Users.search([])
Rate = env['fusion.service.rate']
rates = Rate.search([('rate_kind', '=', 'callout'), ('active', '=', True)])
per_km = Rate.get_rate('per_km')
def labour(code):
r = Rate.get_rate(code)
return r.price if r else 0.0
return {
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
'callout_rates': [{
'code': r.code, 'category': r.category, 'timing': r.timing,
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
} for r in rates],
'per_km': per_km.price if per_km else 0.70,
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
'lift': labour('labour_lift')},
}
@http.route('/fusion_claims/service_booking/search_customers', type='jsonrpc', auth='user')
def search_customers(self, query=None, **kw):
"""Live customer lookup for the booking wizard's 'Existing customer' box.
Matches res.partner by name / phone / mobile / email, and also resolves a
typed sale-order reference to its customer. Returns up to 8 light dicts."""
q = (query or '').strip()
if len(q) < 2:
return {'results': []}
env = request.env
Partner = env['res.partner'].sudo()
# Build the OR domain only over fields that actually exist on this DB —
# res.partner.mobile is NOT present in Odoo 19, so referencing it raises
# ValueError and the whole lookup silently returns nothing.
has_mobile = 'mobile' in Partner._fields
search_fields = [f for f in ('name', 'phone', 'email', 'mobile')
if f in Partner._fields]
leaves = [(f, 'ilike', q) for f in search_fields]
domain = leaves[:1]
for leaf in leaves[1:]:
domain = ['|'] + domain + [leaf]
partners = Partner.search(domain, limit=8, order='write_date desc')
# also resolve an SO number -> its partner (the hint promises "name or SO")
if len(q) >= 3 and len(partners) < 8 and 'sale.order' in env:
sos = env['sale.order'].sudo().search([('name', 'ilike', q)], limit=5)
partners = (partners | sos.mapped('partner_id'))[:8]
results = []
for p in partners:
phone = p.phone or (p.mobile if has_mobile else '') or ''
results.append({
'id': p.id,
'name': p.name or '',
'phone': phone,
'email': p.email or '',
'street': p.street or '',
'city': p.city or '',
})
return {'results': results}
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
def submit(self, payload=None, **kw):
try:
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
except Exception as e:
return {'error': str(e)}

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CALL-OUTS -->
<record id="rate_callout_standard_normal" model="fusion.service.rate">
<field name="name">Standard Service Call</field>
<field name="code">callout_standard_normal</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">95.0</field>
<field name="product_id" ref="product_callout_standard_normal"/>
<field name="sequence">10</field>
</record>
<record id="rate_callout_standard_rush" model="fusion.service.rate">
<field name="name">Rush Service Call (Standard)</field>
<field name="code">callout_standard_rush</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
<field name="product_id" ref="product_callout_standard_rush"/>
<field name="sequence">11</field>
</record>
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
<field name="name">After-Hours Service Call (Standard)</field>
<field name="code">callout_standard_afterhours</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
<field name="product_id" ref="product_callout_standard_afterhours"/>
<field name="sequence">12</field>
</record>
<record id="rate_callout_lift_normal" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Service Call</field>
<field name="code">callout_lift_normal</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">160.0</field>
<field name="product_id" ref="product_callout_lift_normal"/>
<field name="sequence">20</field>
</record>
<record id="rate_callout_lift_rush" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Rush Call</field>
<field name="code">callout_lift_rush</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
<field name="product_id" ref="product_callout_lift_rush"/>
<field name="sequence">21</field>
</record>
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating After-Hours Call</field>
<field name="code">callout_lift_afterhours</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
<field name="product_id" ref="product_callout_lift_afterhours"/>
<field name="sequence">22</field>
</record>
<!-- LABOUR -->
<record id="rate_labour_onsite" model="fusion.service.rate">
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
<field name="rate_kind">labour</field><field name="category">standard</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
<field name="product_id" ref="product_labour_onsite"/><field name="sequence">30</field>
</record>
<record id="rate_labour_lift" model="fusion.service.rate">
<field name="name">Labour — Lift &amp; Elevating</field><field name="code">labour_lift</field>
<field name="rate_kind">labour</field><field name="category">lift</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
<field name="product_id" ref="product_labour_lift"/><field name="sequence">31</field>
</record>
<record id="rate_labour_inshop" model="fusion.service.rate">
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
<field name="product_id" ref="product_labour_inshop"/><field name="sequence">32</field>
</record>
<!-- TRAVEL -->
<record id="rate_per_km" model="fusion.service.rate">
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
<field name="rate_kind">travel</field><field name="category">na</field>
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
<field name="product_id" ref="product_per_km"/><field name="sequence">40</field>
</record>
<!-- DELIVERY / PICKUP -->
<record id="rate_delivery_local" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">35.0</field>
<field name="product_id" ref="product_delivery_local"/><field name="sequence">50</field>
</record>
<record id="rate_delivery_outside" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">60.0</field>
<field name="product_id" ref="product_delivery_outside"/><field name="sequence">51</field>
</record>
<record id="rate_setup_stairlift" model="fusion.service.rate">
<field name="name">Stairlift — Delivery &amp; Set-up</field><field name="code">setup_stairlift</field>
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">300.0</field>
<field name="product_id" ref="product_setup_stairlift"/><field name="sequence">52</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Call-outs (unit) -->
<record id="product_callout_standard_normal" model="product.product">
<field name="name">Service Call — Standard</field>
<field name="default_code">SVC-STD</field>
<field name="type">service</field>
<field name="list_price">95.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_rush" model="product.product">
<field name="name">Service Call — Standard Rush</field>
<field name="default_code">SVC-STD-RUSH</field>
<field name="type">service</field>
<field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_afterhours" model="product.product">
<field name="name">Service Call — Standard After-Hours</field>
<field name="default_code">SVC-STD-AH</field>
<field name="type">service</field>
<field name="list_price">140.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_normal" model="product.product">
<field name="name">Service Call — Lift &amp; Elevating</field>
<field name="default_code">SVC-LIFT</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_rush" model="product.product">
<field name="name">Service Call — Lift &amp; Elevating Rush</field>
<field name="default_code">SVC-LIFT-RUSH</field>
<field name="type">service</field>
<field name="list_price">185.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_afterhours" model="product.product">
<field name="name">Service Call — Lift &amp; Elevating After-Hours</field>
<field name="default_code">SVC-LIFT-AH</field>
<field name="type">service</field>
<field name="list_price">205.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Labour (hour) -->
<record id="product_labour_onsite" model="product.product">
<field name="name">Labour — On-Site</field>
<field name="default_code">LAB-ONSITE</field>
<field name="type">service</field>
<field name="list_price">85.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_lift" model="product.product">
<field name="name">Labour — Lift &amp; Elevating</field>
<field name="default_code">LAB-LIFT</field>
<field name="type">service</field>
<field name="list_price">110.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_inshop" model="product.product">
<field name="name">Labour — In-Shop</field>
<field name="default_code">LAB-INSHOP</field>
<field name="type">service</field>
<field name="list_price">75.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Travel (unit; qty = km x 2) -->
<record id="product_per_km" model="product.product">
<field name="name">Travel — per km (2-way)</field>
<field name="default_code">SVC-KM</field>
<field name="type">service</field>
<field name="list_price">0.70</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Delivery / pickup (unit) -->
<record id="product_delivery_local" model="product.product">
<field name="name">Delivery / Pickup — Local</field>
<field name="default_code">DEL-LOCAL</field>
<field name="type">service</field><field name="list_price">35.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_outside" model="product.product">
<field name="name">Delivery / Pickup — Outside Local Area</field>
<field name="default_code">DEL-OUT</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_rush" model="product.product">
<field name="name">Rush Pickup / Delivery</field>
<field name="default_code">DEL-RUSH</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_liftchair" model="product.product">
<field name="name">Lift Chair — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-LIFTCHAIR</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_hospitalbed" model="product.product">
<field name="name">Hospital Bed — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-BED</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_stairlift" model="product.product">
<field name="name">Stairlift — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_removal_stairlift" model="product.product">
<field name="name">Stairlift — Removal</field>
<field name="default_code">RMV-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -26,4 +26,5 @@ from . import ai_agent_ext
from . import dashboard
from . import res_partner
from . import technician_task
from . import page11_sign_request
from . import page11_sign_request
from . import service_rate

View File

@@ -338,6 +338,11 @@ class SaleOrder(models.Model):
help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
)
x_fc_is_service_repair = fields.Boolean(
string='Service Repair', copy=False,
help='Auto-created from the technician service booking wizard.',
)
x_fc_sale_type_locked = fields.Boolean(
string='Sale Type Locked',
compute='_compute_sale_type_locked',

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class FusionServiceRate(models.Model):
_name = 'fusion.service.rate'
_description = 'Field Service Rate'
_order = 'sequence, rate_kind, category, timing'
name = fields.Char(string='Name', required=True)
code = fields.Char(
string='Code', required=True, index=True,
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
)
rate_kind = fields.Selection([
('callout', 'Service Call-out'),
('labour', 'Labour'),
('travel', 'Travel / per-km'),
('delivery', 'Delivery / Pickup'),
('other', 'Other'),
], string='Kind', required=True, default='callout')
category = fields.Selection([
('standard', 'Standard'),
('lift', 'Lift & Elevating'),
('na', 'N/A'),
], string='Category', default='na')
timing = fields.Selection([
('normal', 'Normal'),
('rush', 'Rush'),
('afterhours', 'After-Hours'),
('na', 'N/A'),
], string='Timing', default='na')
in_shop = fields.Boolean(string='In-Shop')
product_id = fields.Many2one(
'product.product', string='Invoice Product', required=True, ondelete='restrict',
help='Product used on the sale-order line (description, tax, income account).',
)
price = fields.Monetary(
string='Rate', required=True, currency_field='currency_id',
help='Editable price used on the SO line and the on-screen estimate.',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit = fields.Selection([
('fixed', 'Flat'),
('per_hour', 'Per hour'),
('per_km', 'Per km'),
], string='Unit', required=True, default='fixed')
adds_per_km = fields.Boolean(
string='Adds per-km travel',
help='Call-outs billed as $X + per-km \xd7 2-way (rush / after-hours).',
)
included_labour_min = fields.Integer(
string='Included labour (min)', default=0,
help='Free labour minutes bundled into a service call (e.g. 30).',
)
active = fields.Boolean(string='Active', default=True)
sequence = fields.Integer(string='Sequence', default=10)
_unique_code = models.Constraint(
'UNIQUE(code)',
'A service-rate code must be unique.',
)
@api.model
def get_callout(self, category, timing, in_shop=False):
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
if in_shop:
return self.browse()
return self.search([
('rate_kind', '=', 'callout'),
('category', '=', category),
('timing', '=', timing),
], limit=1)
@api.model
def get_rate(self, code):
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
return self.search([('code', '=', code)], limit=1)

View File

@@ -9,7 +9,7 @@ features to the base fusion.technician.task model.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
@@ -72,6 +72,15 @@ class FusionTechnicianTaskClaims(models.Model):
default=False,
)
# ------------------------------------------------------------------
# SERVICE BOOKING FIELDS
# ------------------------------------------------------------------
x_fc_service_call_type = fields.Char(
string='Service Call Type',
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
)
# ------------------------------------------------------------------
# ONCHANGES
# ------------------------------------------------------------------
@@ -104,15 +113,9 @@ class FusionTechnicianTaskClaims(models.Model):
@api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self):
for task in self:
if task.x_fc_sync_source:
continue
if task.task_type == 'ltc_visit':
continue
if not task.sale_order_id and not task.purchase_order_id:
raise ValidationError(_(
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
))
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
# walk-in tasks may legitimately have none. No order link is required anymore.
return
# ------------------------------------------------------------------
# HOOK OVERRIDES
@@ -395,6 +398,166 @@ class FusionTechnicianTaskClaims(models.Model):
order.name, e,
)
# ------------------------------------------------------------------
# SERVICE BOOKING HELPERS
# ------------------------------------------------------------------
@api.model
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
"""Return a list of sale.order.line vals dicts for a service booking,
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
Rate = self.env['fusion.service.rate']
lines = []
callout = Rate.get_callout(category, timing, in_shop=in_shop)
if not callout:
return lines
lines.append({
'product_id': callout.product_id.id,
'name': callout.name,
'product_uom_qty': 1.0,
'price_unit': callout.price,
'name_is_km': False,
})
if callout.adds_per_km and distance_km:
per_km = Rate.get_rate('per_km')
if per_km:
lines.append({
'product_id': per_km.product_id.id,
'name': '%s%.1f km \xd7 2-way' % (per_km.name, distance_km),
'product_uom_qty': round(distance_km * 2.0, 1),
'price_unit': per_km.price,
'name_is_km': True,
})
return lines
@api.model
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines.
Repair-SO identity is the x_fc_is_service_repair boolean (no crm.tag: fusion_claims
has no crm dependency). x_fc_sale_type is intentionally left blank — a service repair
is not one of the ADP/ODSP funder workflows, and the draft is editable afterwards.
"""
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
so_vals = {
'partner_id': partner.id,
'x_fc_is_service_repair': True,
'order_line': order_lines,
}
return self.env['sale.order'].create(so_vals)
def _service_travel_origin(self):
"""Return (lat, lng) of the technician's day-start location, to be used
as the ORIGIN for the per-km travel calculation. NEVER returns the job's
own address (that would give origin == destination == 0 km).
Fallback chain (all read-only — no geocoding API calls here):
1. The technician's personal start address cached coords
(res.users.partner_id.x_fc_start_address_lat/_lng — populated when
the start address is saved, see fusion_tasks/models/res_partner.py).
2. The company HQ start address cached coords, keyed off the
``fusion_claims.technician_start_address`` ICP and cached by
fusion_tasks under ``fusion_tasks.hq_coords:<address>``.
3. (0.0, 0.0) — the correct graceful fallback. _calculate_travel_time
guards on a falsy origin and simply returns False (→ no per-km line).
Geocoding is deliberately NOT performed here: a freshly typed new-client
job address usually has no geocoded destination anyway, so distance is
expected to be 0 in v1. We only avoid passing a WRONG origin.
"""
self.ensure_one()
tech = self.technician_id
if tech:
partner = tech.partner_id
if partner and partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
ICP = self.env['ir.config_parameter'].sudo()
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
if hq_addr:
cached = ICP.get_param('fusion_tasks.hq_coords:%s' % hq_addr, '')
if cached and ',' in cached:
try:
lat_s, lng_s = cached.split(',', 1)
return float(lat_s), float(lng_s)
except (ValueError, TypeError):
pass
return 0.0, 0.0
@api.model
def action_book_from_wizard(self, payload):
"""Single entry point for the OWL booking wizard:
resolve/create contact -> create task -> compute distance -> build SO -> link.
Returns {'task_id', 'order_id'}."""
Partner = self.env['res.partner']
cust = payload.get('customer') or {}
# 1. contact: new -> find-or-create (match email then phone); existing -> chosen partner
if payload.get('cust_mode') == 'new':
partner = False
email = (cust.get('email') or '').strip()
phone = (cust.get('phone') or '').strip()
if email:
partner = Partner.search([('email', '=ilike', email)], limit=1)
if not partner and phone:
partner = Partner.search([('phone', '=', phone)], limit=1)
if not partner:
partner = Partner.create({
'name': cust.get('name') or 'Walk-in',
'phone': phone or False, 'email': email or False,
'street': cust.get('street') or False, 'city': cust.get('city') or False,
})
else:
partner = Partner.browse(int(payload['partner_id'])) if payload.get('partner_id') else Partner
category = payload.get('category', 'standard')
timing = payload.get('timing', 'normal')
in_shop = bool(payload.get('in_shop'))
# technician_id is REQUIRED on a task
technician_id = payload.get('technician_id')
if not technician_id:
raise UserError(_("Please choose a technician for this service booking."))
technician_id = int(technician_id)
# 2. task
dur = float(payload.get('duration_hr') or 1.0)
t_start = float(payload.get('time_start') or 9.0)
task_vals = {
'task_type': 'repair',
'technician_id': technician_id,
'scheduled_date': payload.get('date'),
'time_start': t_start,
'time_end': t_start + dur,
'duration_hours': dur,
'is_in_store': in_shop,
'x_fc_service_call_type': '%s_%s' % (category, timing),
'description': payload.get('description') or payload.get('issue') or _('Service booking'),
}
if partner:
task_vals['partner_id'] = partner.id
task = self.create(task_vals)
# 3. per-km distance: only when the rate adds it AND we have a real origin + a
# geocoded job destination. Origin is the technician's start, never the job.
distance_km = 0.0
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
origin_lat, origin_lng = task._service_travel_origin()
if origin_lat and origin_lng:
try:
task._calculate_travel_time(origin_lat, origin_lng) # sets travel_distance_km
distance_km = task.travel_distance_km or 0.0
except Exception:
distance_km = 0.0
# 4. draft repair SO + link back to the task
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
if order:
task.sale_order_id = order.id
return {'task_id': task.id, 'order_id': order.id if order else False}
# ------------------------------------------------------------------
# VIEW ACTIONS
# ------------------------------------------------------------------

View File

@@ -63,4 +63,6 @@ access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,mod
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
access_fusion_service_rate_admin,fusion.service.rate.admin,model_fusion_service_rate,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
63 access_fusion_page11_sign_request_public fusion.page11.sign.request.public model_fusion_page11_sign_request base.group_public 1 0 0 0
64 access_fusion_send_page11_wizard_user fusion_claims.send.page11.wizard.user model_fusion_claims_send_page11_wizard sales_team.group_sale_salesman 1 1 1 1
65 access_fusion_send_page11_wizard_manager fusion_claims.send.page11.wizard.manager model_fusion_claims_send_page11_wizard sales_team.group_sale_manager 1 1 1 1
66 access_fusion_adp_import_wizard_user fusion_claims.adp.import.wizard.user model_fusion_claims_adp_import_wizard account.group_account_invoice 1 1 1 1
67 access_fusion_service_rate_user fusion.service.rate.user model_fusion_service_rate base.group_user 1 0 0 0
68 access_fusion_service_rate_admin fusion.service.rate.admin model_fusion_service_rate base.group_system 1 1 1 1

View File

@@ -0,0 +1,208 @@
/** @odoo-module **/
import { Component, useState, onWillStart, onMounted, onPatched, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class ServiceBookingWizard extends Component {
static template = "fusion_claims.ServiceBookingWizard";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.orm = useService("orm");
this.rootRef = useRef("root");
this.state = useState({
custMode: "existing",
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "", lat: 0, lng: 0 },
partnerId: false, soSearch: "", custResults: [], custSearching: false,
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
warranty: false, pod: false, emailConfirm: true, googleReview: true,
description: "", materials: "",
technicians: [], calloutRates: [], perKm: 0.70,
labour: { onsite: 85, inshop: 75, lift: 110 }, distanceKm: 13, saving: false,
});
onWillStart(async () => {
const r = await rpc("/fusion_claims/service_booking/refdata", {});
Object.assign(this.state, {
technicians: r.technicians || [],
calloutRates: r.callout_rates || [],
perKm: r.per_km ?? 0.70,
labour: r.labour || this.state.labour,
});
});
// Address autocomplete attaches after the DOM exists and re-attaches when
// OWL re-renders (e.g. switching to "New client" reveals a second address
// input). Fully guarded — a missing Maps key just means manual entry.
onMounted(() => { this._initAddrAutocomplete().catch(() => {}); });
onPatched(() => { this._initAddrAutocomplete().catch(() => {}); });
}
// ---- live customer search (Existing customer box) ----
onCustSearch(ev) {
const q = ev.target.value || "";
this.state.soSearch = q;
this.state.partnerId = false; // typing again unlinks any picked contact
clearTimeout(this._custTimer);
const term = q.trim();
if (term.length < 2) { this.state.custResults = []; this.state.custSearching = false; return; }
this.state.custSearching = true;
this._custTimer = setTimeout(async () => {
try {
const r = await rpc("/fusion_claims/service_booking/search_customers", { query: term });
this.state.custResults = r.results || [];
} catch (e) {
this.state.custResults = [];
}
this.state.custSearching = false;
}, 250);
}
pickCustomer(c) {
this.state.partnerId = c.id;
this.state.customer.name = c.name || "";
this.state.customer.phone = c.phone || "";
this.state.customer.email = c.email || "";
this.state.customer.street = c.street || "";
this.state.customer.city = c.city || "";
this.state.custResults = [];
this.state.soSearch = c.name + (c.phone ? ` · ${c.phone}` : "");
}
// ---- Google Places address autocomplete (wizard-local; the FormController
// patch in google_address_autocomplete.js does NOT reach a client action) ----
async _getMapsKey() {
try {
return await this.orm.call("ir.config_parameter", "get_param", ["fusion_claims.google_maps_api_key"]);
} catch (e) {
return null;
}
}
_loadMaps(key) {
if (window.google?.maps?.places) return Promise.resolve();
if (window._sbMapsLoading) return window._sbMapsLoading;
window._sbMapsLoading = new Promise((resolve, reject) => {
window._sbMapsReady = () => resolve();
const s = document.createElement("script");
s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=places&callback=_sbMapsReady`;
s.async = true; s.defer = true; s.onerror = reject;
document.head.appendChild(s);
});
return window._sbMapsLoading;
}
async _initAddrAutocomplete() {
const root = this.rootRef.el;
if (!root) return;
if (!this._addrStarted) {
if (!root.querySelector("input.sb-addr-input")) return; // nothing to bind yet
this._addrStarted = true;
const key = await this._getMapsKey();
if (!key) { this._addrNoKey = true; return; }
try { await this._loadMaps(key); } catch (e) { return; }
}
if (this._addrNoKey || !window.google?.maps?.places) return;
// re-query after the await: OWL may have swapped inputs during loading
root.querySelectorAll("input.sb-addr-input").forEach((inp) => {
if (inp._sbAc) return;
inp._sbAc = true;
try {
const ac = new google.maps.places.Autocomplete(inp, {
componentRestrictions: { country: "ca" },
types: ["address"],
fields: ["address_components", "formatted_address", "geometry"],
});
ac.addListener("place_changed", () => {
const place = ac.getPlace();
if (!place || !place.address_components) return;
let city = "";
for (const c of place.address_components) {
if (c.types.includes("locality")) city = c.long_name;
else if (c.types.includes("sublocality_level_1") && !city) city = c.long_name;
}
this.state.customer.street = place.formatted_address || inp.value;
if (city) this.state.customer.city = city;
if (place.geometry && place.geometry.location) {
this.state.customer.lat = place.geometry.location.lat();
this.state.customer.lng = place.geometry.location.lng();
}
});
} catch (e) {
inp._sbAc = false; // allow a later retry if construction failed
}
});
}
get callout() {
if (this.state.inShop) return null;
return this.state.calloutRates.find(
r => r.category === this.state.category && r.timing === this.state.timing) || null;
}
get labourRate() {
if (this.state.inShop) return this.state.labour.inshop;
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
}
get estimate() {
const c = this.callout;
const callout = c ? c.price : 0;
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
const billHr = Math.max(0, this.state.durationHr - incl);
const labour = billHr * this.labourRate;
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
}
get endLabel() {
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
}
fmt(n) { return (n || 0).toFixed(2); }
onDevice(ev) {
this.state.device = ev.target.value;
this.state.category = ev.target.value === "lift" ? "lift" : "standard";
}
onCallType(ev) {
const r = this.state.calloutRates.find(x => x.code === ev.target.value);
if (r) { this.state.category = r.category; this.state.timing = r.timing; }
}
setCust(m) { this.state.custMode = m; }
setAmpm(v) { this.state.ampm = v; }
toggleInShop() { this.state.inShop = !this.state.inShop; }
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
async submit() {
if (this.state.saving) return;
const s = this.state;
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
this.notification.add("Client name and phone are required.", { type: "danger" });
return;
}
if (!s.technicianId) {
this.notification.add("Please choose a technician.", { type: "danger" });
return;
}
s.saving = true;
const payload = {
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
email_confirm: s.emailConfirm, google_review: s.googleReview,
description: s.description, materials: s.materials,
};
try {
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
this.action.doAction({
type: "ir.actions.act_window", res_model: "fusion.technician.task",
res_id: res.task_id, views: [[false, "form"]], target: "current",
});
} catch (e) {
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
s.saving = false;
}
}
}
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);

View File

@@ -0,0 +1,73 @@
// Fusion Claims — Service Booking wizard design tokens.
//
// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on
// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into
// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at
// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom
// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode /
// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19.
//
// Values are copied verbatim from the mockup's :root (light) and
// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html.
$o-webclient-color-scheme: bright !default;
// --- light values (mockup :root / [data-theme="light"]) ---
$_page: #eef0f3;
$_panel: #e6e9ed;
$_card: #ffffff;
$_border: #d8dadd;
$_text: #1f2430;
$_muted: #6b7280;
$_faint: #9ca3af;
$_field: #ffffff;
$_field-border: #cfd3d8;
$_field-focus: #3a8fb7;
$_chip: #f1f4f7;
$_accent: #2e7aad;
$_accent-soft: #e8f2f8;
$_ok: #16a34a;
$_star: #f5b301;
$_money: #0f7d4e;
$_money-soft: #e7f6ee;
@if $o-webclient-color-scheme == dark {
// --- dark values (mockup [data-theme="dark"]) ---
$_page: #14161b !global;
$_panel: #1b1e24 !global;
$_card: #22262d !global;
$_border: #343a42 !global;
$_text: #e7eaef !global;
$_muted: #9aa3af !global;
$_faint: #6b7480 !global;
$_field: #1a1d23 !global;
$_field-border: #3a4049 !global;
$_field-focus: #4aa3cf !global;
$_chip: #2a2f37 !global;
$_accent: #3a8fb7 !global;
$_accent-soft: #19303d !global;
$_ok: #22c55e !global;
$_star: #f5b301 !global;
$_money: #34d27f !global;
$_money-soft: #15281f !global;
}
.o_service_booking {
--sb-page: #{$_page};
--sb-panel: #{$_panel};
--sb-card: #{$_card};
--sb-border: #{$_border};
--sb-text: #{$_text};
--sb-muted: #{$_muted};
--sb-faint: #{$_faint};
--sb-field: #{$_field};
--sb-field-border: #{$_field-border};
--sb-field-focus: #{$_field-focus};
--sb-chip: #{$_chip};
--sb-accent: #{$_accent};
--sb-accent-soft: #{$_accent-soft};
--sb-ok: #{$_ok};
--sb-star: #{$_star};
--sb-money: #{$_money};
--sb-money-soft: #{$_money-soft};
}

View File

@@ -0,0 +1,329 @@
// Fusion Claims — Service Booking wizard component styles.
//
// Ported from the mockup (technician-booking-wizard.html) scoped under
// .o_service_booking. The mockup's CSS custom properties (--page, --card, …)
// are renamed mechanically to the --sb-* tokens emitted by
// _service_booking_tokens.scss (which MUST load first in the bundle). The
// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle.
//
// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card ->
// field), never var(--bs-*). color-mix() is used only in standalone
// background / box-shadow properties — never inside a border shorthand (the
// Odoo 19 SCSS compiler silently drops color-mix in border shorthands).
.o_service_booking {
background: var(--sb-page);
color: var(--sb-text);
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
font-size: 14px;
// Fill the action area and scroll INTERNALLY. min-height let the root grow
// to its content height so the (clipping) action container never scrolled;
// height:100% caps it so overflow:auto engages on small screens.
height: 100%;
overflow: auto;
* { box-sizing: border-box; }
.wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; }
.dialog {
background: var(--sb-panel);
border: 1px solid var(--sb-border);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(16, 24, 40, .16);
overflow: hidden;
}
.topbar {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
padding: 17px 24px;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
h1 { font-size: 19px; font-weight: 700; margin: 0; }
.sub { font-size: 12.5px; opacity: .9; margin-top: 2px; }
}
.stepper {
display: flex;
gap: 6px;
padding: 11px 24px;
background: var(--sb-panel);
border-bottom: 1px solid var(--sb-border);
flex-wrap: wrap;
}
.step {
font-size: 11.5px;
font-weight: 600;
color: var(--sb-faint);
padding: 5px 13px;
border-radius: 20px;
background: var(--sb-chip);
}
.step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); }
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
.body { padding: 20px 24px 6px; }
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.sb-card {
background: var(--sb-card);
border: 1px solid var(--sb-border);
border-radius: 13px;
padding: 16px 17px;
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
}
.sb-card.span2 { grid-column: 1 / -1; }
.sb-card h3 {
margin: 0 0 13px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: .7px;
text-transform: uppercase;
color: var(--sb-muted);
display: flex;
align-items: center;
gap: 7px;
}
.sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
.sb-card h3 .tag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
color: var(--sb-money);
background: var(--sb-money-soft);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: .3px;
}
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
.sb-row { margin-bottom: 12px; }
.sb-row:last-child { margin-bottom: 0; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
input.f, select.f, textarea.f {
width: 100%;
background: var(--sb-field);
color: var(--sb-text);
border: 1px solid var(--sb-field-border);
border-radius: 9px;
// !important so Odoo's backend input normalisation can't strip the
// field padding inside a client action.
padding: 10px 12px !important;
font-size: 13.5px;
line-height: 1.4;
font-family: inherit;
outline: none;
transition: border .15s, box-shadow .15s;
}
input.f:focus, select.f:focus, textarea.f:focus {
border-color: var(--sb-field-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent);
}
textarea.f { resize: vertical; min-height: 56px; }
.hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; }
.with-icon { position: relative; }
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
// live customer-search results dropdown
.sb-cust-search { position: relative; }
.sb-cust-results {
position: absolute;
left: 0; right: 0; top: 100%;
margin-top: 4px;
z-index: 30;
background: var(--sb-card);
border: 1px solid var(--sb-border);
border-radius: 9px;
box-shadow: 0 8px 24px rgba(16, 24, 40, .16);
max-height: 260px;
overflow-y: auto;
}
.sb-cust-loading { padding: 10px 12px; font-size: 12.5px; color: var(--sb-faint); }
.sb-cust-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--sb-border); }
.sb-cust-item:last-child { border-bottom: none; }
.sb-cust-item:hover { background: var(--sb-chip); }
.sb-cust-name { font-size: 13.5px; font-weight: 600; color: var(--sb-text); }
.sb-cust-meta { font-size: 11.5px; color: var(--sb-faint); margin-top: 1px; }
.sb-cust-linked { color: var(--sb-ok); font-weight: 600; }
.seg {
display: inline-flex;
background: var(--sb-chip);
border: 1px solid var(--sb-border);
border-radius: 9px;
padding: 3px;
gap: 3px;
}
.seg button {
border: none;
background: transparent;
color: var(--sb-muted);
font-weight: 600;
font-size: 12.5px;
padding: 6px 14px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
}
.seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); }
.seg.full { display: flex; }
.seg.full button { flex: 1; }
.timepick { display: inline-flex; align-items: stretch; gap: 7px; }
.timepick select.f { width: auto; padding-right: 24px; }
.ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; }
.ampm button {
border: none;
background: transparent;
color: var(--sb-muted);
font-weight: 700;
font-size: 12px;
padding: 6px 12px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
}
.ampm button.on { background: var(--sb-accent); color: #fff; }
.endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; }
.endtime b { color: var(--sb-text); }
.avail {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--sb-ok);
background: color-mix(in srgb, var(--sb-ok) 14%, transparent);
padding: 3px 9px;
border-radius: 20px;
margin-top: 6px;
}
.opt {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--sb-border);
}
.opt:last-child { border-bottom: none; }
.opt .lab { font-size: 13.5px; font-weight: 500; }
.opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; }
.sw {
width: 42px;
height: 24px;
border-radius: 20px;
background: var(--sb-field-border);
position: relative;
cursor: pointer;
transition: background .15s;
flex-shrink: 0;
}
.sw::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
top: 3px;
left: 3px;
transition: left .15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
}
.sw.on { background: var(--sb-ok); }
.sw.on::after { left: 21px; }
// fee readout inside Service & Pricing
.feeline {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--sb-money-soft);
border: 1px solid var(--sb-border);
border-radius: 10px;
padding: 11px 14px;
margin-top: 4px;
}
.feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); }
.feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; }
.feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); }
// ESTIMATE strip
.estimate {
grid-column: 1 / -1;
background: var(--sb-money-soft);
border: 1px solid var(--sb-border);
border-left: 5px solid var(--sb-money);
border-radius: 13px;
padding: 15px 18px;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; }
.estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); }
.estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; }
.estimate .total { text-align: right; }
.estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; }
.estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; }
.estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; }
.foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 11px;
padding: 16px 24px;
background: var(--sb-panel);
border-top: 1px solid var(--sb-border);
}
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
.sb-btn {
border: none;
border-radius: 10px;
padding: 11px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
.sb-btn.primary {
color: #fff;
background: linear-gradient(135deg, #5ba848, #2e7aad);
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
}
.sb-btn[disabled] { opacity: .6; cursor: not-allowed; }
.hide { display: none !important; }
// Responsive overrides — MUST come AFTER the base layout rules above. These
// selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity
// as their base rules, so the cascade only lets the media query win when it is
// emitted later in the source. Previously this block sat right after .sb-grid
// (BEFORE the base .two/.three/.timepick rules), so the later base rules
// overrode it and the inner field-grids never collapsed to one column on a
// phone — fields crammed side-by-side. Keep these last.
@media (max-width: 780px) {
.sb-grid { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.wrap { margin: 12px auto; padding: 0 10px; }
.body { padding: 14px 16px 4px; }
.topbar { padding: 14px 16px; }
.foot { padding: 14px 16px; flex-wrap: wrap; }
.two, .three { grid-template-columns: 1fr; }
.timepick { flex-wrap: wrap; }
}
}

View File

@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
<div class="o_service_booking" t-ref="root">
<div class="wrap">
<div class="dialog">
<div class="topbar">
<div>
<h1>Book a Service</h1>
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
</div>
</div>
<div class="stepper">
<span class="step active">Scheduled</span>
<span class="step">En Route</span>
<span class="step">In Progress</span>
<span class="step">Completed</span>
<span class="step draft">● Draft repair SO will be created</span>
</div>
<div class="body">
<div class="sb-grid">
<!-- CUSTOMER -->
<div class="sb-card">
<h3><span class="dot"></span>Customer</h3>
<div class="sb-row">
<div class="seg full">
<button t-att-class="{ on: state.custMode === 'existing' }"
t-on-click="() => this.setCust('existing')">Existing customer</button>
<button t-att-class="{ on: state.custMode === 'new' }"
t-on-click="() => this.setCust('new')">New client</button>
</div>
</div>
<div t-if="state.custMode === 'existing'">
<div class="sb-row sb-cust-search">
<label class="fl">Search by phone, name or SO</label>
<input class="f" t-att-value="state.soSearch" t-on-input="onCustSearch"
placeholder="e.g. (416) 555-0142 …" autocomplete="off"/>
<div class="hint" t-if="!state.partnerId">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
<div class="hint sb-cust-linked" t-if="state.partnerId">✓ Linked to existing contact — booking will use it.</div>
<div class="sb-cust-results" t-if="state.custSearching or state.custResults.length">
<div class="sb-cust-loading" t-if="state.custSearching">Searching…</div>
<t t-else="">
<div class="sb-cust-item" t-foreach="state.custResults" t-as="c" t-key="c.id"
t-on-click="() => this.pickCustomer(c)">
<div class="sb-cust-name"><t t-esc="c.name"/></div>
<div class="sb-cust-meta"><t t-esc="c.phone"/><t t-if="c.phone and c.city"> · </t><t t-esc="c.city"/></div>
</div>
</t>
</div>
</div>
</div>
<div t-if="state.custMode === 'new'">
<div class="sb-row two">
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
</div>
<div class="sb-row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
<div class="sb-row"><label class="fl">Address</label>
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
</div>
<div class="sb-row three">
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
</div>
<div class="hint">Contact is created &amp; linked on save — all from this page.</div>
</div>
</div>
<!-- SERVICE & PRICING -->
<div class="sb-card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="sb-row two">
<div>
<label class="fl">Device being serviced</label>
<select class="f" t-on-change="onDevice">
<option value="standard">Mobility Scooter</option>
<option value="standard">Powerchair</option>
<option value="standard">Wheelchair</option>
<option value="lift">Stairlift</option>
<option value="lift">Patient / Ceiling Lift</option>
<option value="standard">Lift Chair</option>
<option value="standard">Hospital Bed</option>
<option value="standard">Other</option>
</select>
</div>
<div>
<label class="fl">Issue / symptom</label>
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
</div>
</div>
<div class="sb-row" t-if="!state.inShop">
<label class="fl">Service call type</label>
<select class="f"
t-on-change="onCallType">
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
<option t-att-value="r.code"
t-att-selected="state.category === r.category and state.timing === r.timing">
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
</option>
</t>
</select>
<div class="hint">Auto-suggested from the device — change if needed.</div>
</div>
<div class="feeline" t-if="!state.inShop and callout">
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
</div>
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
</div>
<!-- SCHEDULE -->
<div class="sb-card">
<h3><span class="dot"></span>Schedule</h3>
<div class="sb-row two">
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
<div><label class="fl">Duration</label>
<select class="f" t-model.number="state.durationHr">
<option value="0.5">30 min</option>
<option value="1">1 hour</option>
<option value="1.5">1.5 hours</option>
<option value="2">2 hours</option>
<option value="3">3 hours</option>
</select>
</div>
</div>
<div class="sb-row">
<label class="fl">Start time</label>
<div class="timepick">
<select class="f" t-model.number="state.hour">
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<select class="f" t-model.number="state.minute">
<option value="0">:00</option>
<option value="15">:15</option>
<option value="30">:30</option>
<option value="45">:45</option>
</select>
<div class="ampm">
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
</div>
</div>
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
</div>
<div class="sb-row">
<label class="fl">Technician</label>
<select class="f" t-model.number="state.technicianId">
<option value="">— Choose —</option>
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
<option t-att-value="t.id"><t t-esc="t.name"/></option>
</t>
</select>
</div>
</div>
<!-- LOCATION -->
<div class="sb-card">
<h3><span class="dot"></span>Location</h3>
<div class="opt" style="border:none; padding-top:0;">
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
</div>
<div t-if="!state.inShop">
<div class="sb-row"><label class="fl">Job address</label>
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
</div>
<div class="sb-row two">
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
</div>
</div>
</div>
<!-- JOB DETAILS -->
<div class="sb-card span2">
<h3><span class="dot"></span>Job details</h3>
<div class="two">
<div class="sb-row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
<div class="sb-row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
</div>
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
</div>
<!-- ESTIMATE -->
<div class="estimate">
<div class="breakdown">
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop"></t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
</div>
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
</div>
</div>
</div>
<div class="foot">
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
<button class="sb-btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
<button class="sb-btn primary" t-on-click="submit" t-att-disabled="state.saving">Book &amp; Create SO</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -3,3 +3,5 @@
from . import test_signed_pages_gate
from . import test_application_received_wizard
from . import test_dashboard
from . import test_service_rate
from . import test_service_booking

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
from datetime import date, timedelta
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceBooking(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Task = cls.env['fusion.technician.task']
# technician_id is required on a task (domain x_fc_is_field_staff=True).
cls.tech = cls.env['res.users'].create({
'name': 'Service Booking Tech',
'login': 'svcbook_tech',
'x_fc_is_field_staff': True,
})
def test_task_without_order_is_allowed(self):
# No SO/PO must NOT raise after the relax. description is required and a
# non-in-store task needs an address, so set both here to isolate the test
# to the order-link relaxation (not those unrelated base constraints).
t = self.Task.create({
'task_type': 'repair',
'technician_id': self.tech.id,
'scheduled_date': date.today() + timedelta(days=7),
'description': 'Test repair',
'is_in_store': True,
})
self.assertTrue(t.id)
def test_sale_order_has_service_repair_flag(self):
so = self.env['sale.order'].new({})
self.assertIn('x_fc_is_service_repair', so._fields)
def test_resolve_service_lines_standard_rush(self):
Task = self.Task
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
# call-out $120 + per-km line qty 20 @ $0.70
callout = [l for l in lines if l['price_unit'] == 120.0]
per_km = [l for l in lines if l['name_is_km']]
self.assertTrue(callout)
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
self.assertEqual(per_km[0]['price_unit'], 0.70)
def test_resolve_service_lines_in_shop_empty_callout(self):
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
self.assertEqual(lines, [])
def test_build_service_so(self):
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
self.assertEqual(so.state, 'draft')
self.assertTrue(so.x_fc_is_service_repair)
self.assertEqual(so.partner_id, partner)
self.assertEqual(so.order_line[0].price_unit, 95.0)
def test_action_book_creates_contact_task_and_so(self):
payload = {
'cust_mode': 'new',
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
'street': '88 Bloor St E', 'city': 'Toronto'},
'category': 'standard', 'timing': 'normal', 'in_shop': False,
'device': 'scooter', 'issue': "won't power on",
'date': (date.today() + timedelta(days=7)).strftime('%Y-%m-%d'), 'time_start': 9.0, 'duration_hr': 1.0,
'technician_id': self.tech.id, 'description': 'check battery',
}
res = self.Task.action_book_from_wizard(payload)
self.assertTrue(res['task_id'] and res['order_id'])
task = self.Task.browse(res['task_id'])
self.assertEqual(task.sale_order_id.id, res['order_id'])
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
self.assertTrue(partner)

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceRate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Rate = cls.env['fusion.service.rate']
cls.product = cls.env['product.product'].create({
'name': 'Test Service Product', 'type': 'service',
})
def _make(self, **kw):
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
vals.update(kw)
return self.Rate.create(vals)
def test_get_callout_matches_category_and_timing(self):
# Assert against the real seed (codes are unique, so creating colliding
# standard/normal rows would violate the UNIQUE(code) constraint).
r = self.Rate.get_callout('standard', 'normal')
self.assertTrue(r)
self.assertEqual(r.code, 'callout_standard_normal')
self.assertEqual(r.rate_kind, 'callout')
def test_get_callout_in_shop_returns_empty(self):
self._make(code='callout_standard_normal_b')
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
def test_get_rate_by_code(self):
# 'per_km' is a seeded code; the resolver returns that row.
r = self.Rate.get_rate('per_km')
self.assertTrue(r)
self.assertEqual(r.unit, 'per_km')
def test_code_must_be_unique(self):
self._make(code='dup')
with self.assertRaises(Exception):
self._make(code='dup')
self.env.flush_all()
def test_seeded_callouts_exist(self):
# standard normal $95, lift after-hours $205 are the canonical seeds
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
self.assertEqual(std.price, 95.0)
self.assertEqual(std.rate_kind, 'callout')
self.assertTrue(std.product_id)
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
self.assertEqual(lift_ah.price, 205.0)
self.assertTrue(lift_ah.adds_per_km)
def test_seeded_per_km(self):
km = self.env['fusion.service.rate'].get_rate('per_km')
self.assertTrue(km)
self.assertEqual(km.unit, 'per_km')
self.assertEqual(km.price, 0.70)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<record id="action_service_booking_wizard" model="ir.actions.client">
<field name="name">Book a Service</field>
<field name="tag">fusion_claims.service_booking</field>
</record>
<menuitem id="menu_service_booking"
name="Book a Service"
parent="fusion_tasks.menu_field_service_root"
action="action_service_booking_wizard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- ===================================================================== -->
<!-- SERVICE RATE: List View (inline-edit enabled) -->
<!-- ===================================================================== -->
<record id="view_fusion_service_rate_list" model="ir.ui.view">
<field name="name">fusion.service.rate.list</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<list string="Service Rates" editable="top"
default_order="sequence, rate_kind, category, timing">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="rate_kind" string="Kind"/>
<field name="category"/>
<field name="timing"/>
<field name="unit"/>
<field name="price" string="Rate"/>
<field name="currency_id" column_invisible="True"/>
<field name="adds_per_km" string="+ km"/>
<field name="included_labour_min" string="Incl. Labour (min)"/>
<field name="in_shop" string="In-Shop"/>
<field name="product_id" string="Invoice Product"/>
<field name="active" column_invisible="True"/>
</list>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Form View -->
<!-- ===================================================================== -->
<record id="view_fusion_service_rate_form" model="ir.ui.view">
<field name="name">fusion.service.rate.form</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<form string="Service Rate">
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Rate name…"/></h1>
</div>
<group>
<group string="Identification">
<field name="code"/>
<field name="rate_kind" string="Kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
<field name="active"/>
<field name="sequence"/>
</group>
<group string="Pricing">
<field name="price" string="Rate"/>
<field name="currency_id"/>
<field name="unit"/>
<field name="adds_per_km"/>
<field name="included_labour_min"/>
</group>
</group>
<group string="Invoice Product">
<field name="product_id" string="Product" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Action -->
<!-- ===================================================================== -->
<record id="action_fusion_service_rate" model="ir.actions.act_window">
<field name="name">Service Rates</field>
<field name="res_model">fusion.service.rate</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No service rates found.
</p>
<p>
Add rates used for booking service calls, labour, travel, and delivery.
</p>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Menu item under Technician Configuration -->
<!-- ===================================================================== -->
<menuitem id="menu_fusion_service_rate"
name="Service Rates"
parent="fusion_tasks.menu_technician_config"
action="action_fusion_service_rate"
sequence="50"
groups="base.group_system"/>
</odoo>

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({

Binary file not shown.

View File

@@ -44,7 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition``Adresse d'expédition`, `N° de pièce``N° de pièce`, `—``â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition``Adresse d'expédition`, `N° de pièce``N° de pièce`, `—``â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). **EXCEPTION — do NOT add `.article` to the dpi=96 mm-based job stickers** (`fusion_plating_jobs/report/report_fp_job_sticker.xml`): those set a custom `@page` + `dpi=96` so mm maps 1:1 (rule 14), and wrapping their body in `<div class="article">` re-routes through Odoo's standard report CSS which **blows up the mm/dpi layout** — the logo + every element renders huge (tested + reverted 2026-06-04). For those labels do the OPPOSITE: leave the raw `html_container` and **strip the offending non-ASCII glyph to ASCII in the Python display helper** instead (`fp_job_sticker.py::_clean()` maps `º`/`°`/`˚`→'' , em-dash→`-`, smart-quotes→ASCII). Trade-off: you lose the literal glyph (a bake temp `375°F` prints `375F`) but the label stays clean + correctly sized. So: `.article` for normal-flow custom-header reports, ASCII-strip for the fixed-dpi mm labels. | any custom-header PDF report on entech wkhtmltopdf |
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
@@ -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 — Certificates',
'version': '19.0.9.3.0',
'version': '19.0.10.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Migrate the single Default CoC Contact (Many2one) to the multi-contact
Many2many.
The field x_fc_default_coc_contact_id (Many2one column on res_partner) was
renamed to x_fc_default_coc_contact_ids (self-referential Many2many, rel
table fp_default_coc_contact_rel). Odoo creates the new rel table during the
schema-update phase but leaves the old column orphaned. Copy each partner's
single contact into the new M2m so existing per-customer CoC routing carries
over, then drop the dead column. Idempotent + guarded on column existence.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
# Old column still present (Odoo doesn't drop removed-field columns)?
cr.execute("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'res_partner'
AND column_name = 'x_fc_default_coc_contact_id'
""")
if not cr.fetchone():
return
# New M2m rel table created by the schema update before this runs.
cr.execute("""
SELECT 1 FROM information_schema.tables
WHERE table_name = 'fp_default_coc_contact_rel'
""")
if not cr.fetchone():
_logger.warning(
'fp_default_coc_contact_rel missing — skipping CoC contact '
'migration (rel table not created yet).')
return
# Copy the single value into the M2m (skip rows already present so a
# re-run is harmless).
cr.execute("""
INSERT INTO fp_default_coc_contact_rel (partner_id, contact_id)
SELECT p.id, p.x_fc_default_coc_contact_id
FROM res_partner p
WHERE p.x_fc_default_coc_contact_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM fp_default_coc_contact_rel r
WHERE r.partner_id = p.id
AND r.contact_id = p.x_fc_default_coc_contact_id)
""")
moved = cr.rowcount
cr.execute(
"ALTER TABLE res_partner DROP COLUMN IF EXISTS "
"x_fc_default_coc_contact_id")
_logger.info(
'CoC contact migration: copied %s single Default-CoC-Contact value(s) '
'into the new x_fc_default_coc_contact_ids M2m, dropped old column.',
moved)

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Migrate the cert's single Customer Contact (Many2one) to a Many2many.
fp.certificate.contact_partner_id (single Many2one column) was renamed to
contact_partner_ids (Many2many -> res.partner, rel
fp_certificate_contact_partner_rel) so a cert can carry every contact who
receives the CoC. Odoo creates the new rel table during the schema-update
phase but leaves the old column orphaned. Copy each cert's single contact
into the new M2m (becomes the primary / printed addressee), then drop the
dead column. Guarded + idempotent.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
cr.execute("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'fp_certificate'
AND column_name = 'contact_partner_id'
""")
if not cr.fetchone():
return
cr.execute("""
SELECT 1 FROM information_schema.tables
WHERE table_name = 'fp_certificate_contact_partner_rel'
""")
if not cr.fetchone():
_logger.warning(
'fp_certificate_contact_partner_rel missing — skipping cert '
'customer-contact migration (rel table not created yet).')
return
cr.execute("""
INSERT INTO fp_certificate_contact_partner_rel (cert_id, partner_id)
SELECT c.id, c.contact_partner_id
FROM fp_certificate c
WHERE c.contact_partner_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM fp_certificate_contact_partner_rel r
WHERE r.cert_id = c.id
AND r.partner_id = c.contact_partner_id)
""")
moved = cr.rowcount
cr.execute(
"ALTER TABLE fp_certificate DROP COLUMN IF EXISTS contact_partner_id")
_logger.info(
'Cert customer-contact migration: copied %s single contact_partner_id '
'value(s) into the new contact_partner_ids M2m, dropped old column.',
moved)

View File

@@ -58,11 +58,18 @@ class FpCertificate(models.Model):
string='Customer Job No.',
help="Customer's internal job / traveler reference.",
)
contact_partner_id = fields.Many2one(
'res.partner', string='Customer Contact',
contact_partner_ids = fields.Many2many(
'res.partner',
relation='fp_certificate_contact_partner_rel',
column1='cert_id', column2='partner_id',
string='Customer Contact',
domain="[('parent_id', '=', partner_id)]",
help="Specific contact person at the customer for this certificate. "
'Their name, email, and phone are printed on the CoC.',
help="Contacts at the customer who receive this certificate. "
"Auto-filled from the customer's Default CoC Contacts when a "
'job ships. The first is the primary (its name, email and '
'phone print on the CoC); ALL of them are emailed when the '
'cert is sent to the customer. (Renamed from the single '
'contact_partner_id — see migration 19.0.10.3.0.)',
)
issued_by_id = fields.Many2one(
'res.users', string='Issued By', default=lambda self: self.env.user,
@@ -436,6 +443,47 @@ class FpCertificate(models.Model):
rec.invalidate_recordset(['name'])
return records
def _fp_needs_thickness_data(self):
"""True when this cert MUST carry thickness data to be issued.
Single source of truth shared by action_issue (hard gate) and the
Issue-Certs wizard (readiness hint) so the two can never drift.
Partner side — the ceiling: a CoC needs thickness when the customer
is strict-thickness (aerospace / Nadcap) or opts into the
thickness-on-CoC bundle; a thickness_report cert always needs it.
Recipe side — suppress-only: a recipe whose requires_thickness_report
is False (passivation, chemical conversion, anodize seal-only — no
plating thickness physically exists) REMOVES the requirement even
when the customer asked. This mirrors Step 2 of
fp.job._resolve_required_cert_types so the cert-type resolver and
this issue-time gate agree. Without it, a passivation CoC for a
thickness customer can never be issued (the gate demands Fischerscope
data the process cannot produce). Field-existence guards keep this
safe when fusion_plating_jobs / fusion_plating are at an older
schema or not installed.
"""
self.ensure_one()
partner = self.partner_id
needs = (
self.certificate_type == 'thickness_report'
or (self.certificate_type == 'coc' and partner and (
('x_fc_strict_thickness_required' in partner._fields
and partner.x_fc_strict_thickness_required)
or ('x_fc_send_thickness_report' in partner._fields
and partner.x_fc_send_thickness_report)
))
)
if not needs:
return False
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
recipe = job.recipe_id if job else False
if (recipe and 'requires_thickness_report' in recipe._fields
and not recipe.requires_thickness_report):
return False
return True
# ----- State actions ----------------------------------------------------
def action_issue(self):
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
@@ -478,12 +526,15 @@ class FpCertificate(models.Model):
# was configured would still trip the gate even after sales
# set the default. Robust-by-construction: the defaults take
# effect retroactively at issue time.
if (not rec.contact_partner_id
if (not rec.contact_partner_ids
and rec.partner_id
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
and rec.partner_id.x_fc_default_coc_contact_id):
rec.contact_partner_id = (
rec.partner_id.x_fc_default_coc_contact_id
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
and rec.partner_id.x_fc_default_coc_contact_ids):
# Auto-fill ALL the customer's CoC contacts. The first is
# the primary (printed on the CoC); every contact is emailed
# when the cert is sent (action_send_to_customer).
rec.contact_partner_ids = (
rec.partner_id.x_fc_default_coc_contact_ids
)
# Lazy-fill the signer from the LIVE company owner (Settings
# "Certificate Owner") when no per-cert / per-spec signer was
@@ -537,27 +588,27 @@ class FpCertificate(models.Model):
'(Settings > Fusion Plating).'
) % {'name': rec.name or rec.display_name})
# Customer contact — the named recipient printed on the
# cert and emailed when it ships. Auto-filled from
# partner.x_fc_default_coc_contact_id when set.
if not rec.contact_partner_id:
# cert and emailed when it ships. Auto-filled from the FIRST
# of partner.x_fc_default_coc_contact_ids when set.
if not rec.contact_partner_ids:
raise UserError(_(
'Cannot issue certificate "%(name)s" — Customer '
'Contact is not set.\n\nPick the recipient contact, '
'or configure a Default CoC Contact on customer '
'Contact is not set.\n\nPick the recipient contact(s), '
'or configure Default CoC Contacts on customer '
'"%(cust)s".'
) % {
'name': rec.name or rec.display_name,
'cust': rec.partner_id.name if rec.partner_id else '?',
})
if not (rec.contact_partner_id.email or '').strip():
if not (rec.contact_partner_ids[:1].email or '').strip():
raise UserError(_(
'Cannot issue certificate "%(name)s" — contact '
'"%(c)s" has no email address.\n\nAdd an email '
'to the contact before issuing (the cert is sent '
'by email post-issue).'
'Cannot issue certificate "%(name)s" primary contact '
'"%(c)s" has no email address.\n\nAdd an email to the '
'contact before issuing (the cert is sent by email '
'post-issue).'
) % {
'name': rec.name or rec.display_name,
'c': rec.contact_partner_id.name,
'c': rec.contact_partner_ids[:1].name,
})
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
# are manual-attach only — operator uploads supplier doc /
@@ -580,30 +631,18 @@ class FpCertificate(models.Model):
'type': type_label,
'name': rec.name or rec.display_name,
})
# Thickness data requirement — unified gate covering both
# cert types. A customer needs thickness data on the cert
# when ANY of these is true:
# 1. cert type is thickness_report (the cert IS the data)
# 2. partner.x_fc_strict_thickness_required (aerospace /
# Nadcap — always strict)
# 3. partner.x_fc_send_thickness_report (the bundling
# rule — CoC carries thickness as page 2 by default
# for these customers; see CLAUDE.md "CoC + thickness
# = ONE cert (page 2 merge)")
# Acceptable data: logged readings on the cert OR a
# Fischerscope PDF on the linked QC OR a cert-local
# Fischerscope upload. Any one is enough.
# Thickness data requirement — _fp_needs_thickness_data is the
# single source of truth (shared with the Issue-Certs wizard).
# A customer needs thickness data when the cert is a
# thickness_report, or it's a CoC and the partner is
# strict-thickness / opts into the thickness-on-CoC bundle
# UNLESS the job's recipe suppresses thickness
# (requires_thickness_report=False: passivation, chemical
# conversion, anodize seal-only — no plating thickness exists).
# Acceptable data: logged readings on the cert OR a Fischerscope
# PDF on the linked QC OR a cert-local Fischerscope upload.
partner = rec.partner_id
needs_thickness = (
rec.certificate_type == 'thickness_report'
or (rec.certificate_type == 'coc' and partner and (
('x_fc_strict_thickness_required' in partner._fields
and partner.x_fc_strict_thickness_required)
or ('x_fc_send_thickness_report' in partner._fields
and partner.x_fc_send_thickness_report)
))
)
if needs_thickness:
if rec._fp_needs_thickness_data():
has_readings = bool(rec.thickness_reading_ids)
has_qc_fischer_pdf = bool(
rec.x_fc_thickness_pdf_id
@@ -1085,11 +1124,18 @@ class FpCertificate(models.Model):
"""Open email composer with the certificate PDF attached."""
self.ensure_one()
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
# Send to ALL the Customer Contacts defined on this cert (auto-filled
# from the customer's Default CoC Contacts at job creation), falling
# back to the company. The CoC goes to exactly the contacts shown on
# the cert's Customer Contact field.
recipients = self.contact_partner_ids
partner_ids = recipients.ids or (
[self.partner_id.id] if self.partner_id else [])
ctx = {
'default_model': 'fp.certificate',
'default_res_ids': self.ids,
'default_composition_mode': 'comment',
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
'default_partner_ids': partner_ids,
}
if self.attachment_id:
ctx['default_attachment_ids'] = [self.attachment_id.id]

View File

@@ -128,17 +128,24 @@ class ResPartner(models.Model):
'who require specific NIST or DFARS language.',
)
# ---- Default CoC contact (cert addressee + email recipient) ----------
# The single named contact printed on the CoC and used as the email
# default when the cert ships. Sales sets it once per customer.
# Falls back to manual selection at action_issue time if blank.
x_fc_default_coc_contact_id = fields.Many2one(
# ---- Default CoC contacts (cert addressees + email recipients) -------
# One or more named contacts who receive this customer's CoC. The first
# is the primary (printed on the cert + pre-fills cert.contact_partner_id
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
# sets the list once per customer. Falls back to manual selection at
# action_issue time if blank. Self-referential M2m (renamed from the old
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
x_fc_default_coc_contact_ids = fields.Many2many(
'res.partner',
string='Default CoC Contact',
relation='fp_default_coc_contact_rel',
column1='partner_id', column2='contact_id',
string='Default CoC Contacts',
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
tracking=True,
help='Default contact the Certificate of Conformance is addressed '
'to and emailed to. Pre-fills cert.contact_partner_id when a '
'job ships. Leave blank to force the manager to pick at '
'issue time. Must be a child contact of this company.',
help='Contacts the Certificate of Conformance is addressed to and '
'emailed to. The first contact is the primary (printed on the '
'cert and pre-filled as the cert contact when a job ships); '
'the rest are copied (CC) when the CoC is sent. Leave blank to '
'force the manager to pick at issue time. Child contacts of '
'this company only.',
)

View File

@@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase):
'spec_reference': 'AMS 2404',
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
'certified_by_id': self.signer.id,
'contact_partner_id': self.contact_with_email.id,
'contact_partner_ids': [(6, 0, [self.contact_with_email.id])],
}
vals.update(kw)
return self.env['fp.certificate'].create(vals)
@@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase):
# ---- new gate: contact_partner_id ----
def test_blocks_on_missing_contact(self):
cert = self._make_cert(contact_partner_id=False)
cert = self._make_cert(contact_partner_ids=False)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Customer Contact', str(exc.exception))
def test_blocks_on_contact_without_email(self):
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
cert = self._make_cert(contact_partner_ids=[(6, 0, [self.contact_no_email.id])])
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('no email', str(exc.exception))
@@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase):
spec_reference=False,
process_description=False,
certified_by_id=False,
contact_partner_id=False,
contact_partner_ids=False,
)
with self.assertRaises(UserError) as exc:
cert.action_issue()

View File

@@ -101,7 +101,8 @@
<group>
<field name="certificate_type"/>
<field name="partner_id"/>
<field name="contact_partner_id"
<field name="contact_partner_ids"
widget="many2many_tags"
options="{'no_create': True}"
invisible="not partner_id"/>
<field name="sale_order_id"/>

View File

@@ -44,15 +44,17 @@
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
</group>
</group>
<separator string="Default CoC Contact"/>
<separator string="Default CoC Contacts"/>
<p class="text-muted">
The named contact this customer's CoC is addressed
to and emailed to. Pre-fills cert records when a
job ships. Leave blank to force the manager to pick
at issue time.
The contacts this customer's CoC is addressed to and
emailed to. The first is the primary (printed on the
cert); the rest are copied (CC) when the CoC is sent.
Pre-fills cert records when a job ships. Leave blank
to force the manager to pick at issue time.
</p>
<group>
<field name="x_fc_default_coc_contact_id"
<field name="x_fc_default_coc_contact_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
</group>
<separator string="Cert Statement Override (Sub 12c+)"/>

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.4.0',
'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

@@ -2379,7 +2379,14 @@ class FpJob(models.Model):
if 'contact_name' in Delivery._fields and ship_to.name:
vals['contact_name'] = ship_to.name
if 'contact_phone' in Delivery._fields:
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
# res.partner has no `mobile` field in this Odoo 19 build —
# guard it so the read can't AttributeError (and still picks
# up mobile on instances that do define it).
vals['contact_phone'] = (
ship_to.phone
or (ship_to.mobile if 'mobile' in ship_to._fields else '')
or ''
)
# Scheduled date — operator can adjust; this just primes it
# so they're not staring at a blank field.
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
@@ -2671,7 +2678,7 @@ class FpJob(models.Model):
(a per-spec override). Left empty
otherwise so the LIVE company owner
resolves at render / issue time.
- contact_partner_id ← partner.x_fc_default_coc_contact_id
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
Honours part.certificate_requirement (coc / coc_thickness /
@@ -2706,8 +2713,11 @@ class FpJob(models.Model):
signer = spec.signer_user_id
# Contact: per-customer default; blank means manager picks at issue.
contact = False
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
contact = self.partner_id.x_fc_default_coc_contact_id
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
# ALL the customer's CoC contacts -> the cert's Customer Contact
# M2m. First is the primary (printed on the CoC); every contact
# is emailed at send (fp.certificate.action_send_to_customer).
contact = self.partner_id.x_fc_default_coc_contact_ids
# NC qty: scrapped + visual rejects. Both NULL-safe.
nc_qty = int(
(self.qty_scrapped or 0)
@@ -2777,8 +2787,8 @@ class FpJob(models.Model):
vals['process_description'] = recipe.name or ''
if 'certified_by_id' in Cert._fields and signer:
vals['certified_by_id'] = signer.id
if 'contact_partner_id' in Cert._fields and contact:
vals['contact_partner_id'] = contact.id
if 'contact_partner_ids' in Cert._fields and contact:
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)

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',
@@ -397,6 +486,45 @@ class FpJobStep(models.Model):
step.state = 'cancelled'
return True
def button_reset(self):
"""Reset a step back to 'ready' so it can be redone — operator
self-serve for a mistake, an accidental skip, or a customer redo
request. Clears the finish + sign-off stamps and closes any open
timelog so the redo re-captures them; KEEPS the first-start audit
(date_started / started_by) and the move / CoC history intact.
Posts a chatter audit on the parent job. No-op on a step already
ready/pending (nothing to undo).
"""
now = fields.Datetime.now()
for step in self:
if step.state in ('ready', 'pending'):
continue
prev_label = dict(
step._fields['state'].selection
).get(step.state, step.state)
open_logs = step.time_log_ids.filtered(
lambda l: not l.date_finished)
if open_logs:
open_logs.write({'date_finished': now, 'state': 'stopped'})
vals = {'state': 'ready'}
if 'date_finished' in step._fields:
vals['date_finished'] = False
if 'finished_by_user_id' in step._fields:
vals['finished_by_user_id'] = False
if 'signoff_user_id' in step._fields:
vals['signoff_user_id'] = False
step.write(vals)
if step.job_id:
step.job_id.message_post(body=_(
'Step "%(s)s" reset to Ready (was %(p)s) by %(u)s '
'for redo.'
) % {
's': step.name or '?',
'p': prev_label,
'u': self.env.user.display_name,
})
return True
def write(self, vals):
"""Post a chatter trail on the parent JOB whenever an active
step gets reassigned. The step itself already tracks
@@ -698,19 +826,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
@@ -747,6 +885,16 @@ class FpJobStep(models.Model):
Prompt = self.env['fusion.plating.process.node.input']
if not node:
return Prompt
# Master switch (Sub 12d): when the recipe node opts OUT of
# measurement collection, the Record-Inputs wizard returns ZERO
# rows (fp_job_step_input_wizard.default_get). The finish gate MUST
# agree — otherwise required prompts are demanded with no way to
# enter them and the step is permanently stuck (bake nodes with
# collect_measurements=False but required prompts — WO-30098 + 63
# others on entech). Honour the switch here so gate <=> wizard.
if ('collect_measurements' in node._fields
and not node.collect_measurements):
return Prompt
prompts = node.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input')

View File

@@ -0,0 +1,113 @@
# -*- 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'', '...'),
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
# type for "375ºF", the real degree '°' (U+00B0), and the ring
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
# html_container path (no .article UTF-8 wrapper — and adding one
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
(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,
# Real thickness present (has a digit) — drives the prominent
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
'has_thk': bool(thk and any(c.isdigit() for c in 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

@@ -8,6 +8,7 @@
# reaches state='done'.
from odoo import _, fields, models
from odoo.exceptions import UserError
class FpReceiving(models.Model):
@@ -65,3 +66,34 @@ class FpReceiving(models.Model):
if len(jobs) == 1:
action.update({'view_mode': 'form', 'res_id': jobs.id})
return action
# ---- Sticker printing from the Receiving screen (2026-06-04) ----------
# Both stickers loop the SO's boxes (one label per box). Pass a SINGLE
# work order: the box loop is sale-order-scoped, so feeding every job
# would reprint each box label once per job. One job → exactly one label
# per box. Falls back to a single 1/1 label when no boxes exist yet.
def _fp_sticker_jobs(self):
self.ensure_one()
if not self.sale_order_id:
return self.env['fp.job']
return self.env['fp.job'].sudo().search(
[('sale_order_id', '=', self.sale_order_id.id)], order='id', limit=1)
def _fp_print_sticker(self, xmlid):
self.ensure_one()
jobs = self._fp_sticker_jobs()
if not jobs:
raise UserError(_(
'No work order exists for this receiving yet — create the '
'Work Order before printing stickers.'))
return self.env.ref(xmlid).report_action(jobs)
def action_print_external_sticker(self):
"""Customer (external) box sticker(s) for this receiving's WO."""
return self._fp_print_sticker(
'fusion_plating_jobs.action_report_fp_job_sticker')
def action_print_internal_sticker(self):
"""Shop (internal) box sticker(s) — same layout, internal notes."""
return self._fp_print_sticker(
'fusion_plating_jobs.action_report_fp_job_sticker_internal')

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>
@@ -91,41 +57,202 @@
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<!-- NOT bound to the fp.job Print menu (removed from the list per
request). Printed via the Receiving screen button
(fp.receiving.action_print_internal_sticker). eval=False clears
any binding a prior install left in the DB. -->
<field name="binding_model_id" eval="False"/>
<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: 9mm; line-height: 9mm; text-align: center; }
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
.r-wo { height: 13mm; 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: 32mm; 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: 32mm; }
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
/* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
30mm -> 27mm so the QR-driven black band is shorter and the freed
height flows to the NOTES block below. Still well above scan size. */
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
.qfwrap-int img { position: absolute; width: 33.75mm; height: 33.75mm; top: -3.375mm; left: -3.375mm; }
.r-fld { padding: 0.7mm 2.2mm; }
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.m-thk { padding: 1.8mm 2.6mm 2.4mm; }
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
</style>
</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><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
</tr></table></div>
</div>
<div class="main">
<!-- Plating thickness — the team's most-watched spec. Relocated
out of the cramped rail to a prominent full-width banner at
the top of the main panel. -->
<t t-if="d['has_thk']">
<div class="m-thk rule"><span class="inshead">PLATING THICKNESS</span>
<div style="font-size:21pt;font-weight:900;line-height:1;margin-top:1.4mm"><t t-esc="d['thk']"/></div>
</div>
</t>
<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"><t t-esc="_notes_label or '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) ===================== -->
<!-- Internal sticker = a COPY of the External (Layout B, one per box) but
showing the INTERNAL description instead of the customer-facing notes,
and a "INTERNAL NOTES" header so the shop copy can't be confused with
the customer copy. Identical rail/QR/logo/box otherwise. -->
<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="_notes_label" t-value="'INTERNAL 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>
<!-- ===================== 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="_notes_label" t-value="'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

@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
'name': 'CertCust',
'is_company': True,
'x_fc_send_coc': True,
'x_fc_default_coc_contact_id': cls.contact.id,
'x_fc_default_coc_contact_ids': [(6, 0, [cls.contact.id])],
})
cls.contact.parent_id = cls.partner.id
cls.product = cls.env['product.product'].create({
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.contact_partner_id, self.contact)
self.assertEqual(cert.contact_partner_ids, self.contact)
def test_create_cert_computes_nc_quantity(self):
job = self._make_job(

View File

@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
'certificate_type': 'nadcap_cert',
'state': 'draft',
'partner_id': self.partner.id,
'contact_partner_id': self.partner.id,
'contact_partner_ids': [(6, 0, [self.partner.id])],
'spec_reference': 'AMS 2404',
'process_description': 'TEST PROCESS',
'certified_by_id': self.env.user.id,
@@ -141,3 +141,48 @@ class TestRecipeCertSuppression(TransactionCase):
cert.with_context(
fp_skip_cert_authority_gate=True
).action_issue()
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
"""A passivation recipe (requires_thickness_report=False) must drop
the thickness-data requirement at issue time too — even for a
strict-thickness customer. Regression: the recipe-suppression
feature updated _resolve_required_cert_types but NOT the
action_issue / wizard thickness gate, so passivation CoCs could
never be issued (gate demanded Fischerscope data the process
cannot produce)."""
self.partner.x_fc_send_coc = True
self.partner.x_fc_send_thickness_report = True
self.partner.x_fc_strict_thickness_required = True
self.recipe.requires_thickness_report = False
part = self._make_part()
job = self._make_job(part_catalog_id=part.id)
cert = self.env['fp.certificate'].create({
'name': 'TEST-COC-PASSIVATION',
'certificate_type': 'coc',
'state': 'draft',
'partner_id': self.partner.id,
'x_fc_job_id': job.id,
})
# Recipe suppresses thickness -> the shared gate must NOT demand it.
self.assertFalse(cert._fp_needs_thickness_data())
# ---- Test 8: normal recipe still enforces thickness (control) ----
def test_normal_recipe_keeps_thickness_issue_gate(self):
"""Control for Test 7: a recipe that allows thickness
(requires_thickness_report default True) still demands thickness
data on the CoC for a thickness customer. Guards against
over-suppression weakening real aerospace enforcement."""
self.partner.x_fc_send_coc = True
self.partner.x_fc_send_thickness_report = True
# self.recipe.requires_thickness_report stays default True.
part = self._make_part()
job = self._make_job(part_catalog_id=part.id)
cert = self.env['fp.certificate'].create({
'name': 'TEST-COC-NORMAL',
'certificate_type': 'coc',
'state': 'draft',
'partner_id': self.partner.id,
'x_fc_job_id': job.id,
})
self.assertTrue(cert._fp_needs_thickness_data())

View File

@@ -100,8 +100,8 @@
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/>
<field name="customer_spec_id" string="Specification"/>
<field name="recipe_id" string="Process Recipe"/>
<field name="customer_spec_id" string="Specification" invisible="1"/>
<field name="recipe_id" string="Process Recipe" readonly="1"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty
picture is visible at a glance without opening Move Log. -->
@@ -118,7 +118,7 @@
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
<field name="step_ids" mode="list"
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}">
<list editable="bottom"
<list editable="bottom" create="false" delete="false"
decoration-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'"
decoration-warning="state == 'paused'"
@@ -162,6 +162,14 @@
title="Finish &amp; Next" icon="fa-check-circle"
class="btn-link o_fp_finish_btn"
invisible="state != 'in_progress'"/>
<!-- Reset / redo — back to Ready so the step can be
run again (mistake, accidental skip, customer
redo). Clears finish + sign-off stamps; keeps the
start audit + moves. Hidden on ready/pending. -->
<button name="button_reset" type="object"
title="Reset step (redo)" icon="fa-undo"
class="btn-link text-warning"
invisible="state in ('ready', 'pending')"/>
<!-- Secondary actions — small icons only. Pause is
only relevant on a running step; Record Inputs
@@ -302,6 +310,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

@@ -25,6 +25,17 @@
class="btn-primary" icon="fa-cogs"
invisible="not x_fc_show_work_order_btn"
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
<!-- Print box stickers for this receiving's work order — one
label per tracked box (external = customer copy, internal
= shop copy with internal notes). Shown once a WO exists. -->
<button name="action_print_external_sticker"
string="External Sticker" type="object" icon="fa-print"
invisible="x_fc_fp_job_count == 0"
help="Print the customer (external) box sticker(s) — one per box."/>
<button name="action_print_internal_sticker"
string="Internal Sticker" type="object" icon="fa-print"
invisible="x_fc_fp_job_count == 0"
help="Print the shop (internal) box sticker(s) — same layout, internal notes."/>
</xpath>
<!-- Work Order smart button on the button_box (mirrors the

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

@@ -461,17 +461,18 @@ class FpCertIssueWizardLine(models.TransientModel):
@api.depends('cert_id.certificate_type',
'cert_id.partner_id.x_fc_send_thickness_report',
'cert_id.partner_id.x_fc_strict_thickness_required')
'cert_id.partner_id.x_fc_strict_thickness_required',
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
def _compute_needs_thickness(self):
# Delegate to fp.certificate._fp_needs_thickness_data — the single
# source of truth shared with the action_issue hard gate — so the
# wizard's readiness hint and the gate can never drift. Honours
# recipe-level thickness suppression (passivation = no thickness
# even if the customer asked).
for ln in self:
cert = ln.cert_id
partner = cert.partner_id
ln.needs_thickness = (
cert.certificate_type == 'thickness_report'
or (cert.certificate_type == 'coc' and partner and (
partner.x_fc_strict_thickness_required
or partner.x_fc_send_thickness_report
))
ln.cert_id._fp_needs_thickness_data()
if ln.cert_id else False
)
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',

View File

@@ -3,11 +3,16 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpDelivery(models.Model):
"""Scheduled delivery of finished parts back to a customer.
@@ -480,6 +485,51 @@ class FpDelivery(models.Model):
or rec.vehicle_id.display_name
or 'Driver'),
)
# Packing slip travels with the shipment — render + attach it on
# dispatch so the driver/customer get it and it's on the record.
self._fp_generate_packing_slip()
def _fp_generate_packing_slip(self, force=False):
"""Render each delivery's packing slip and store it on
packing_list_attachment_id so it ships with the goods.
Fired on dispatch (action_start_route) and as a catch-all on
action_mark_delivered. Idempotent + best-effort: skips deliveries
that already carry a slip (unless force=True) and never raises —
a report glitch must not block shipping. The report action is
resolved at runtime so this module needs no hard dependency on
fusion_plating_reports.
"""
report_xmlid = (
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait'
)
report = self.env.ref(report_xmlid, raise_if_not_found=False)
if not report:
return
for rec in self:
if 'packing_list_attachment_id' not in rec._fields:
return
if rec.packing_list_attachment_id and not force:
continue
try:
report_model = self.env['ir.actions.report'].sudo()
pdf_bytes, _fmt = report_model._render_qweb_pdf(
report_xmlid, res_ids=rec.ids)
att = self.env['ir.attachment'].sudo().create({
'name': _('Packing Slip - %s.pdf') % rec.display_name,
'type': 'binary',
'datas': base64.b64encode(pdf_bytes),
'mimetype': 'application/pdf',
'res_model': rec._name,
'res_id': rec.id,
})
rec.packing_list_attachment_id = att.id
rec.message_post(
body=_('Packing slip generated and attached.'))
except Exception as exc:
_logger.warning(
'Packing slip render failed for delivery %s: %s',
rec.display_name, exc)
def action_mark_delivered(self):
"""Block "delivered" until a Proof of Delivery exists.
@@ -511,6 +561,9 @@ class FpDelivery(models.Model):
# Sub 8 — box-parity warning. Non-blocking; just posts to
# chatter so the shipping supervisor sees it on the record.
rec._fp_check_box_parity()
# Catch-all: ensure a slip exists even if dispatch was skipped
# (generate-if-missing — won't overwrite the dispatch-time one).
self._fp_generate_packing_slip()
def _fp_check_box_parity(self):
"""Compare this delivery's boxes-out count to the boxes-in count

View File

@@ -377,7 +377,7 @@ class FpNotificationTemplate(models.Model):
# Packing slip — gated by customer preference (default True)
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
att = _render_report(
'fusion_plating_reports.action_report_fp_packing_slip_portrait', delivery,
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait', delivery,
)
if att:
ids.append(att)

View File

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

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