Compare commits

..

50 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
84 changed files with 3719 additions and 3266 deletions

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 - Add new gotchas in the right format
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview` - Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
- Know the deployment fact that fusion_portal is always co-installed - 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 models
from . import wizard from . import wizard
from . import controllers
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Claims', 'name': 'Fusion Claims',
'version': '19.0.9.2.0', 'version': '19.0.9.7.0',
'category': 'Sales', 'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """ 'description': """
@@ -98,9 +98,13 @@
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/product_labor_data.xml', 'data/product_labor_data.xml',
'data/service_rate_products.xml',
'data/service_rate_data.xml',
'wizard/status_change_reason_wizard_views.xml', 'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml', 'views/res_company_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/service_rate_views.xml',
'views/service_booking_action.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/account_move_views.xml', 'views/account_move_views.xml',
'views/account_journal_views.xml', 'views/account_journal_views.xml',
@@ -181,12 +185,20 @@
# Dashboard OWL countdown widget # Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js', 'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml', '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': [ 'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark # Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly. # $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_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.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'], '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

@@ -27,3 +27,4 @@ from . import dashboard
from . import res_partner from . import res_partner
from . import technician_task 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.', 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( x_fc_sale_type_locked = fields.Boolean(
string='Sale Type Locked', string='Sale Type Locked',
compute='_compute_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 import models, fields, api, _
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError
from markupsafe import Markup from markupsafe import Markup
import logging import logging
@@ -72,6 +72,15 @@ class FusionTechnicianTaskClaims(models.Model):
default=False, 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 # ONCHANGES
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -104,15 +113,9 @@ class FusionTechnicianTaskClaims(models.Model):
@api.constrains('sale_order_id', 'purchase_order_id') @api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self): def _check_order_link(self):
for task in self: # Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
if task.x_fc_sync_source: # walk-in tasks may legitimately have none. No order link is required anymore.
continue return
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."
))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# HOOK OVERRIDES # HOOK OVERRIDES
@@ -395,6 +398,166 @@ class FusionTechnicianTaskClaims(models.Model):
order.name, e, 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 ACTIONS
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -64,3 +64,5 @@ access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model
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_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_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
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_signed_pages_gate
from . import test_application_received_wizard from . import test_application_received_wizard
from . import test_dashboard 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>

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 | | **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` | | **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 | | **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` | | **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 | | **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 | | **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 |

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -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 — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.10.0.0', 'version': '19.0.10.3.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """ '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

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

View File

@@ -58,11 +58,18 @@ class FpCertificate(models.Model):
string='Customer Job No.', string='Customer Job No.',
help="Customer's internal job / traveler reference.", help="Customer's internal job / traveler reference.",
) )
contact_partner_id = fields.Many2one( contact_partner_ids = fields.Many2many(
'res.partner', string='Customer Contact', 'res.partner',
relation='fp_certificate_contact_partner_rel',
column1='cert_id', column2='partner_id',
string='Customer Contact',
domain="[('parent_id', '=', partner_id)]", domain="[('parent_id', '=', partner_id)]",
help="Specific contact person at the customer for this certificate. " help="Contacts at the customer who receive this certificate. "
'Their name, email, and phone are printed on the CoC.', "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( issued_by_id = fields.Many2one(
'res.users', string='Issued By', default=lambda self: self.env.user, 'res.users', string='Issued By', default=lambda self: self.env.user,
@@ -87,10 +94,6 @@ class FpCertificate(models.Model):
thickness_reading_ids = fields.One2many( thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'certificate_id', string='Thickness Readings', 'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
) )
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
# ----- Inline Fischerscope PDF upload (cert-local) ---------------------- # ----- Inline Fischerscope PDF upload (cert-local) ----------------------
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the # The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
@@ -440,6 +443,47 @@ class FpCertificate(models.Model):
rec.invalidate_recordset(['name']) rec.invalidate_recordset(['name'])
return records 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 ---------------------------------------------------- # ----- State actions ----------------------------------------------------
def action_issue(self): def action_issue(self):
# ===== ACL guard (spec 2026-05-25 §ACL changes) =============== # ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
@@ -482,12 +526,15 @@ class FpCertificate(models.Model):
# was configured would still trip the gate even after sales # was configured would still trip the gate even after sales
# set the default. Robust-by-construction: the defaults take # set the default. Robust-by-construction: the defaults take
# effect retroactively at issue time. # effect retroactively at issue time.
if (not rec.contact_partner_id if (not rec.contact_partner_ids
and rec.partner_id and rec.partner_id
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
and rec.partner_id.x_fc_default_coc_contact_id): and rec.partner_id.x_fc_default_coc_contact_ids):
rec.contact_partner_id = ( # Auto-fill ALL the customer's CoC contacts. The first is
rec.partner_id.x_fc_default_coc_contact_id # 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 # Lazy-fill the signer from the LIVE company owner (Settings
# "Certificate Owner") when no per-cert / per-spec signer was # "Certificate Owner") when no per-cert / per-spec signer was
@@ -541,27 +588,27 @@ class FpCertificate(models.Model):
'(Settings > Fusion Plating).' '(Settings > Fusion Plating).'
) % {'name': rec.name or rec.display_name}) ) % {'name': rec.name or rec.display_name})
# Customer contact — the named recipient printed on the # Customer contact — the named recipient printed on the
# cert and emailed when it ships. Auto-filled from # cert and emailed when it ships. Auto-filled from the FIRST
# partner.x_fc_default_coc_contact_id when set. # of partner.x_fc_default_coc_contact_ids when set.
if not rec.contact_partner_id: if not rec.contact_partner_ids:
raise UserError(_( raise UserError(_(
'Cannot issue certificate "%(name)s" — Customer ' 'Cannot issue certificate "%(name)s" — Customer '
'Contact is not set.\n\nPick the recipient contact, ' 'Contact is not set.\n\nPick the recipient contact(s), '
'or configure a Default CoC Contact on customer ' 'or configure Default CoC Contacts on customer '
'"%(cust)s".' '"%(cust)s".'
) % { ) % {
'name': rec.name or rec.display_name, 'name': rec.name or rec.display_name,
'cust': rec.partner_id.name if rec.partner_id else '?', '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(_( raise UserError(_(
'Cannot issue certificate "%(name)s" — contact ' 'Cannot issue certificate "%(name)s" primary contact '
'"%(c)s" has no email address.\n\nAdd an email ' '"%(c)s" has no email address.\n\nAdd an email to the '
'to the contact before issuing (the cert is sent ' 'contact before issuing (the cert is sent by email '
'by email post-issue).' 'post-issue).'
) % { ) % {
'name': rec.name or rec.display_name, '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) # Orphan cert types (Nadcap / Mill Test / Customer-Specific)
# are manual-attach only — operator uploads supplier doc / # are manual-attach only — operator uploads supplier doc /
@@ -584,30 +631,18 @@ class FpCertificate(models.Model):
'type': type_label, 'type': type_label,
'name': rec.name or rec.display_name, 'name': rec.name or rec.display_name,
}) })
# Thickness data requirement — unified gate covering both # Thickness data requirement — _fp_needs_thickness_data is the
# cert types. A customer needs thickness data on the cert # single source of truth (shared with the Issue-Certs wizard).
# when ANY of these is true: # A customer needs thickness data when the cert is a
# 1. cert type is thickness_report (the cert IS the data) # thickness_report, or it's a CoC and the partner is
# 2. partner.x_fc_strict_thickness_required (aerospace / # strict-thickness / opts into the thickness-on-CoC bundle
# Nadcap — always strict) # UNLESS the job's recipe suppresses thickness
# 3. partner.x_fc_send_thickness_report (the bundling # (requires_thickness_report=False: passivation, chemical
# rule — CoC carries thickness as page 2 by default # conversion, anodize seal-only — no plating thickness exists).
# for these customers; see CLAUDE.md "CoC + thickness # Acceptable data: logged readings on the cert OR a Fischerscope
# = ONE cert (page 2 merge)") # PDF on the linked QC OR a cert-local Fischerscope upload.
# 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.
partner = rec.partner_id partner = rec.partner_id
needs_thickness = ( if rec._fp_needs_thickness_data():
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:
has_readings = bool(rec.thickness_reading_ids) has_readings = bool(rec.thickness_reading_ids)
has_qc_fischer_pdf = bool( has_qc_fischer_pdf = bool(
rec.x_fc_thickness_pdf_id rec.x_fc_thickness_pdf_id
@@ -1089,11 +1124,18 @@ class FpCertificate(models.Model):
"""Open email composer with the certificate PDF attached.""" """Open email composer with the certificate PDF attached."""
self.ensure_one() self.ensure_one()
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False) 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 = { ctx = {
'default_model': 'fp.certificate', 'default_model': 'fp.certificate',
'default_res_ids': self.ids, 'default_res_ids': self.ids,
'default_composition_mode': 'comment', '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: if self.attachment_id:
ctx['default_attachment_ids'] = [self.attachment_id.id] ctx['default_attachment_ids'] = [self.attachment_id.id]

View File

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

View File

@@ -128,17 +128,24 @@ class ResPartner(models.Model):
'who require specific NIST or DFARS language.', 'who require specific NIST or DFARS language.',
) )
# ---- Default CoC contact (cert addressee + email recipient) ---------- # ---- Default CoC contacts (cert addressees + email recipients) -------
# The single named contact printed on the CoC and used as the email # One or more named contacts who receive this customer's CoC. The first
# default when the cert ships. Sales sets it once per customer. # is the primary (printed on the cert + pre-fills cert.contact_partner_id
# Falls back to manual selection at action_issue time if blank. # when a job ships); the rest are CC'd when the CoC is emailed. Sales
x_fc_default_coc_contact_id = fields.Many2one( # 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', '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)]", domain="[('parent_id', '=', id), ('is_company', '=', False)]",
tracking=True, tracking=True,
help='Default contact the Certificate of Conformance is addressed ' help='Contacts the Certificate of Conformance is addressed to and '
'to and emailed to. Pre-fills cert.contact_partner_id when a ' 'emailed to. The first contact is the primary (printed on the '
'job ships. Leave blank to force the manager to pick at ' 'cert and pre-filled as the cert contact when a job ships); '
'issue time. Must be a child contact of this company.', '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

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

View File

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

View File

@@ -101,7 +101,8 @@
<group> <group>
<field name="certificate_type"/> <field name="certificate_type"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="contact_partner_id" <field name="contact_partner_ids"
widget="many2many_tags"
options="{'no_create': True}" options="{'no_create': True}"
invisible="not partner_id"/> invisible="not partner_id"/>
<field name="sale_order_id"/> <field name="sale_order_id"/>
@@ -152,21 +153,6 @@
invisible="trend_alert == 'ok'"/> invisible="trend_alert == 'ok'"/>
</group> </group>
<notebook> <notebook>
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
<page string="Thickness Readings" name="readings"> <page string="Thickness Readings" name="readings">
<field name="thickness_reading_ids"> <field name="thickness_reading_ids">
<list editable="bottom"> <list editable="bottom">

View File

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

View File

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

View File

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

View File

@@ -609,47 +609,38 @@ class FpJob(models.Model):
matches the defensive pattern used elsewhere in this file. matches the defensive pattern used elsewhere in this file.
""" """
self.ensure_one() self.ensure_one()
# ---- Step 1 — partner + part baseline (union across all parts) ---- # ---- Step 1 — partner + part baseline ----
def _partner_inherit_set(): req = (
s = set() self.part_catalog_id
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
wanted = set()
p = self.partner_id p = self.partner_id
if p: if p:
if p.x_fc_send_coc: if p.x_fc_send_coc:
s.add('coc') wanted.add('coc')
if p.x_fc_send_thickness_report: if p.x_fc_send_thickness_report:
s.add('thickness_report') wanted.add('thickness_report')
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert: # Three aerospace/defence partner toggles. Field guards
s.add('nadcap_cert') # let this module load even if fusion_plating_certificates
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test: # is at an older version that pre-dates the new fields.
s.add('mill_test') if ('x_fc_send_nadcap_cert' in p._fields
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific: and p.x_fc_send_nadcap_cert):
s.add('customer_specific') wanted.add('nadcap_cert')
return s if ('x_fc_send_mill_test' in p._fields
and p.x_fc_send_mill_test):
def _explicit_set(req): wanted.add('mill_test')
return { if ('x_fc_send_customer_specific' in p._fields
'none': set(), 'coc': {'coc'}, and p.x_fc_send_customer_specific):
wanted.add('customer_specific')
else:
wanted = {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'}, 'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'}) }.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
if not parts:
parts = [False]
wanted = set()
inherit = None
for part in parts:
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
# ---- Step 2 — recipe suppression (suppress-only) ---- # ---- Step 2 — recipe suppression (suppress-only) ----
recipe = self.recipe_id recipe = self.recipe_id
if recipe: if recipe:
@@ -2388,7 +2379,14 @@ class FpJob(models.Model):
if 'contact_name' in Delivery._fields and ship_to.name: if 'contact_name' in Delivery._fields and ship_to.name:
vals['contact_name'] = ship_to.name vals['contact_name'] = ship_to.name
if 'contact_phone' in Delivery._fields: 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 # Scheduled date — operator can adjust; this just primes it
# so they're not staring at a blank field. # so they're not staring at a blank field.
if so and so.commitment_date and 'scheduled_date' in Delivery._fields: if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
@@ -2664,58 +2662,6 @@ class FpJob(models.Model):
self.name, e, self.name, e,
) )
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
# fp_customer_description() is a method (configurator), not a
# field — use hasattr, not a _fields check.
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc,
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
def _fp_create_certificates(self): def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by """Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running _resolve_required_cert_types. Idempotent per type — re-running
@@ -2732,7 +2678,7 @@ class FpJob(models.Model):
(a per-spec override). Left empty (a per-spec override). Left empty
otherwise so the LIVE company owner otherwise so the LIVE company owner
resolves at render / issue time. 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 - nc_quantity ← qty_scrapped + qty_visual_insp_rejects
Honours part.certificate_requirement (coc / coc_thickness / Honours part.certificate_requirement (coc / coc_thickness /
@@ -2767,8 +2713,11 @@ class FpJob(models.Model):
signer = spec.signer_user_id signer = spec.signer_user_id
# Contact: per-customer default; blank means manager picks at issue. # Contact: per-customer default; blank means manager picks at issue.
contact = False contact = False
if 'x_fc_default_coc_contact_id' in self.partner_id._fields: if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
contact = self.partner_id.x_fc_default_coc_contact_id # 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: scrapped + visual rejects. Both NULL-safe.
nc_qty = int( nc_qty = int(
(self.qty_scrapped or 0) (self.qty_scrapped or 0)
@@ -2803,7 +2752,10 @@ class FpJob(models.Model):
# spec_reference is what action_issue blocks on. # spec_reference is what action_issue blocks on.
# Format spec.code + revision for the cert text. # Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields: if spec and 'spec_reference' in Cert._fields:
ref = self._fp_format_spec_ref(spec) ref = spec.code or ''
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
if ref: if ref:
vals['spec_reference'] = ref vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields: if 'customer_spec_id' in Cert._fields:
@@ -2835,14 +2787,10 @@ class FpJob(models.Model):
vals['process_description'] = recipe.name or '' vals['process_description'] = recipe.name or ''
if 'certified_by_id' in Cert._fields and signer: if 'certified_by_id' in Cert._fields and signer:
vals['certified_by_id'] = signer.id vals['certified_by_id'] = signer.id
if 'contact_partner_id' in Cert._fields and contact: if 'contact_partner_ids' in Cert._fields and contact:
vals['contact_partner_id'] = contact.id vals['contact_partner_ids'] = [(6, 0, contact.ids)]
if 'entech_wo_number' in Cert._fields: if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or '' vals['entech_wo_number'] = self.name or ''
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
cert = Cert.create(vals) cert = Cert.create(vals)
self.message_post(body=Markup(_( self.message_post(body=Markup(_(
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer ' '%(t)s <b>%(n)s</b> auto-created (draft). Issuer '

View File

@@ -486,6 +486,45 @@ class FpJobStep(models.Model):
step.state = 'cancelled' step.state = 'cancelled'
return True 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): def write(self, vals):
"""Post a chatter trail on the parent JOB whenever an active """Post a chatter trail on the parent JOB whenever an active
step gets reassigned. The step itself already tracks step gets reassigned. The step itself already tracks
@@ -846,6 +885,16 @@ class FpJobStep(models.Model):
Prompt = self.env['fusion.plating.process.node.input'] Prompt = self.env['fusion.plating.process.node.input']
if not node: if not node:
return Prompt 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 prompts = node.input_ids
if 'kind' in prompts._fields: if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input') prompts = prompts.filtered(lambda i: i.kind == 'step_input')

View File

@@ -18,7 +18,13 @@ def _clean(text):
t = str(text) t = str(text)
for a, b in ((u'', '-'), (u'', '-'), (u'', "'"), for a, b in ((u'', '-'), (u'', '-'), (u'', "'"),
(u'', "'"), (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) t = t.replace(a, b)
return t.strip() return t.strip()
@@ -88,6 +94,9 @@ class FpJob(models.Model):
'qty': qty, 'qty': qty,
'due': due_s, 'due': due_s,
'thk': thk, '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), '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 '', '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 '', 'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',

View File

@@ -8,6 +8,7 @@
# reaches state='done'. # reaches state='done'.
from odoo import _, fields, models from odoo import _, fields, models
from odoo.exceptions import UserError
class FpReceiving(models.Model): class FpReceiving(models.Model):
@@ -65,3 +66,34 @@ class FpReceiving(models.Model):
if len(jobs) == 1: if len(jobs) == 1:
action.update({'view_mode': 'form', 'res_id': jobs.id}) action.update({'view_mode': 'form', 'res_id': jobs.id})
return action 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

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

View File

@@ -57,8 +57,11 @@
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field> <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="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="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/> <!-- NOT bound to the fp.job Print menu (removed from the list per
<field name="binding_type">report</field> 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"/> <field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record> </record>
@@ -84,13 +87,13 @@
/* Layout B rail + main */ /* Layout B rail + main */
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; } .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; } .main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
.r-logo { height: 11mm; line-height: 11mm; text-align: center; } .r-logo { height: 9mm; line-height: 9mm; text-align: center; }
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; } .r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
.r-wo { height: 14mm; background: #000; color: #fff; padding: 0; } .r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; } .wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; } .wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; } .bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
.r-qrflags { height: 36mm; text-align: center; } .r-qrflags { height: 32mm; text-align: center; }
.qftbl { border-collapse: collapse; width: 100%; height: 100%; } .qftbl { border-collapse: collapse; width: 100%; height: 100%; }
.qftbl td { vertical-align: middle; text-align: center; } .qftbl td { vertical-align: middle; text-align: center; }
.qfqr { width: 66%; } .qfqr { width: 66%; }
@@ -104,72 +107,23 @@
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; } .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 { 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; } .qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
.qffull { line-height: 36mm; } .qffull { line-height: 32mm; }
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; } .qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; } .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, bigger box. */ /* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; } 30mm -> 27mm so the QR-driven black band is shorter and the freed
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; } height flows to the NOTES block below. Still well above scan size. */
.r-fld { padding: 1mm 2.2mm; } .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 { border-collapse: collapse; width: 100%; height: 100%; }
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; } .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-bake { padding: 1.3mm 2.6mm 1.8mm; }
.m-notes { padding: 1.3mm 2.6mm 3.5mm; } .m-notes { padding: 1.3mm 2.6mm 3.5mm; }
</style> </style>
</template> </template>
<!-- ===================== Internal body — Layout A ===================== -->
<template id="fp_job_internal_body">
<div class="label-page"><div class="label">
<table class="fpt">
<tr style="height:22mm" class="rule">
<td class="band pad">
<span style="float:right"><span class="tag">INTERNAL</span></span>
<span class="lbl" style="color:#fff">Work Order</span>
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
</td>
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;vertical-align:middle;padding:1mm">
<span class="qfwrap-int"><img t-att-src="_qr"/></span>
</td>
</tr>
<tr style="height:12mm" class="rule">
<td class="pad" colspan="2">
<span class="lbl">Part#</span>
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
<span style="float:right">
<t t-if="d['mask']"><span class="badge">MASK</span></t>
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
</span>
</td>
</tr>
<tr style="height:10mm" class="rule">
<td style="padding:0" colspan="2"><table class="fpt"><tr>
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></td>
</tr>
<t t-if="d['bake']">
<tr style="height:13mm" class="rule">
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
<span class="inshead">BAKE</span>
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
</td>
</tr>
</t>
<tr>
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
<span class="inshead">NOTES</span>
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
</td>
</tr>
</table>
</div></div>
</template>
<!-- ===================== External body — Layout B ===================== --> <!-- ===================== External body — Layout B ===================== -->
<template id="fp_job_external_body"> <template id="fp_job_external_body">
<div class="label-page"><div class="label"> <div class="label-page"><div class="label">
@@ -215,17 +169,24 @@
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></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> </tr></table></div>
<div style="height:8.5mm"><table class="gtbl"><tr> <div style="height:8.5mm"><table class="gtbl"><tr>
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td> <td><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></div> </tr></table></div>
</div> </div>
<div class="main"> <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']"> <t t-if="d['bake']">
<div class="m-bake rule"><span class="inshead">BAKE</span> <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 class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
</div> </div>
</t> </t>
<div class="m-notes"><span class="inshead">NOTES</span> <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 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>
@@ -233,16 +194,35 @@
</template> </template>
<!-- ===================== Internal outer (per job) ===================== --> <!-- ===================== 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"> <template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/> <t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
<t t-foreach="docs" t-as="job"> <t t-foreach="docs" t-as="job">
<t t-set="d" t-value="job._fp_sticker_data()"/> <t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['internal_notes']"/> <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="_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="_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-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
<t t-call="fusion_plating_jobs.fp_job_internal_body"/> <t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t> </t>
</t> </t>
</template> </template>
@@ -254,6 +234,7 @@
<t t-foreach="docs" t-as="job"> <t t-foreach="docs" t-as="job">
<t t-set="d" t-value="job._fp_sticker_data()"/> <t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['customer_notes']"/> <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="_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="_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="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>

View File

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

View File

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

View File

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

View File

@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
'name': 'CertCust', 'name': 'CertCust',
'is_company': True, 'is_company': True,
'x_fc_send_coc': 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.contact.parent_id = cls.partner.id
cls.product = cls.env['product.product'].create({ cls.product = cls.env['product.product'].create({
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
cert = self.env['fp.certificate'].search([ cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id), ('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): def test_create_cert_computes_nc_quantity(self):
job = self._make_job( job = self._make_job(

View File

@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
'certificate_type': 'nadcap_cert', 'certificate_type': 'nadcap_cert',
'state': 'draft', 'state': 'draft',
'partner_id': self.partner.id, 'partner_id': self.partner.id,
'contact_partner_id': self.partner.id, 'contact_partner_ids': [(6, 0, [self.partner.id])],
'spec_reference': 'AMS 2404', 'spec_reference': 'AMS 2404',
'process_description': 'TEST PROCESS', 'process_description': 'TEST PROCESS',
'certified_by_id': self.env.user.id, 'certified_by_id': self.env.user.id,
@@ -141,3 +141,48 @@ class TestRecipeCertSuppression(TransactionCase):
cert.with_context( cert.with_context(
fp_skip_cert_authority_gate=True fp_skip_cert_authority_gate=True
).action_issue() ).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

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

View File

@@ -100,8 +100,8 @@
</xpath> </xpath>
<xpath expr="//field[@name='product_id']" position="after"> <xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/> <field name="part_catalog_id" string="Part"/>
<field name="customer_spec_id" string="Specification"/> <field name="customer_spec_id" string="Specification" invisible="1"/>
<field name="recipe_id" string="Process Recipe"/> <field name="recipe_id" string="Process Recipe" readonly="1"/>
</xpath> </xpath>
<!-- Show qty completed alongside total so the partial-qty <!-- Show qty completed alongside total so the partial-qty
picture is visible at a glance without opening Move Log. --> 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"> <xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
<field name="step_ids" mode="list" <field name="step_ids" mode="list"
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}"> 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-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'" decoration-success="state == 'done'"
decoration-warning="state == 'paused'" decoration-warning="state == 'paused'"
@@ -162,6 +162,14 @@
title="Finish &amp; Next" icon="fa-check-circle" title="Finish &amp; Next" icon="fa-check-circle"
class="btn-link o_fp_finish_btn" class="btn-link o_fp_finish_btn"
invisible="state != 'in_progress'"/> 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 <!-- Secondary actions — small icons only. Pause is
only relevant on a running step; Record Inputs only relevant on a running step; Record Inputs

View File

@@ -25,6 +25,17 @@
class="btn-primary" icon="fa-cogs" class="btn-primary" icon="fa-cogs"
invisible="not x_fc_show_work_order_btn" 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."/> 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> </xpath>
<!-- Work Order smart button on the button_box (mirrors the <!-- Work Order smart button on the button_box (mirrors the

View File

@@ -461,17 +461,18 @@ class FpCertIssueWizardLine(models.TransientModel):
@api.depends('cert_id.certificate_type', @api.depends('cert_id.certificate_type',
'cert_id.partner_id.x_fc_send_thickness_report', '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): 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: for ln in self:
cert = ln.cert_id
partner = cert.partner_id
ln.needs_thickness = ( ln.needs_thickness = (
cert.certificate_type == 'thickness_report' ln.cert_id._fp_needs_thickness_data()
or (cert.certificate_type == 'coc' and partner and ( if ln.cert_id else False
partner.x_fc_strict_thickness_required
or partner.x_fc_send_thickness_report
))
) )
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids', @api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',

View File

@@ -3,11 +3,16 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
import base64
import logging
from markupsafe import Markup from markupsafe import Markup
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpDelivery(models.Model): class FpDelivery(models.Model):
"""Scheduled delivery of finished parts back to a customer. """Scheduled delivery of finished parts back to a customer.
@@ -480,6 +485,51 @@ class FpDelivery(models.Model):
or rec.vehicle_id.display_name or rec.vehicle_id.display_name
or 'Driver'), 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): def action_mark_delivered(self):
"""Block "delivered" until a Proof of Delivery exists. """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 # Sub 8 — box-parity warning. Non-blocking; just posts to
# chatter so the shipping supervisor sees it on the record. # chatter so the shipping supervisor sees it on the record.
rec._fp_check_box_parity() 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): def _fp_check_box_parity(self):
"""Compare this delivery's boxes-out count to the boxes-in count """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) # Packing slip — gated by customer preference (default True)
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'): if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
att = _render_report( 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: if att:
ids.append(att) ids.append(att)

View File

@@ -74,6 +74,15 @@ class SaleOrder(models.Model):
'expected_qty': int(total_qty), 'expected_qty': int(total_qty),
'line_ids': line_vals, 'line_ids': line_vals,
}) })
# Seamless flow: after a single interactive confirm, jump straight
# to the Receive Parts screen so the dock counts parts in right away
# (idiot-proof — no hunting for the smart button). Guarded to a
# single order + an opt-out context flag so batch / programmatic
# confirms (and tests) keep the native return value.
if (len(self) == 1
and not self.env.context.get('fp_no_receiving_redirect')
and self.x_fc_receiving_ids):
return self.action_view_receiving()
return res return res
def action_view_receiving(self): def action_view_receiving(self):

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Reports', 'name': 'Fusion Plating — Reports',
'version': '19.0.11.35.0', 'version': '19.0.11.36.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.', 'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [ 'depends': [
@@ -25,6 +25,11 @@
# creates a cycle. Our only fp.job touchpoint is wo_scan.py which # creates a cycle. Our only fp.job touchpoint is wo_scan.py which
# uses runtime env.get('fp.job') — safe without the manifest dep. # uses runtime env.get('fp.job') — safe without the manifest dep.
'fusion_plating_logistics', 'fusion_plating_logistics',
# Needed for the packing-slip Print binding on fp.receiving
# (binding_model_id ref). Already a transitive dep via logistics;
# declared explicitly so the ref is robust. No cycle — receiving
# does not depend on reports.
'fusion_plating_receiving',
], ],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',

View File

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

View File

@@ -461,4 +461,271 @@
</t> </t>
</template> </template>
<!-- ============================================================= -->
<!-- LOCAL DELIVERY variant (fusion.plating.delivery) -->
<!-- The stock.picking templates above don't fit shops that ship -->
<!-- via fusion.plating.delivery (no picking). This variant -->
<!-- resolves the SO + lines from the delivery's job_ref (same -->
<!-- pattern as the BoL report) and reuses the shared styles / -->
<!-- address / signoff bits. Items come from the SO lines (vs -->
<!-- stock moves). -->
<!-- ============================================================= -->
<!-- Items table sourced from sale.order.line (not stock.move) -->
<template id="fp_packing_slip_items_lines">
<table class="bordered fp-ps-items-table">
<thead>
<tr>
<th t-att-style="w_ordered or 'width: 8%;'">
Ordered<span class="fp-fr">Comm.</span>
</th>
<th t-att-style="w_shipped or 'width: 8%;'">
Shipped<span class="fp-fr">EXP</span>
</th>
<th t-att-style="w_bo or 'width: 8%;'">
B/O<span class="fp-fr">À venir</span>
</th>
<th class="text-start" t-att-style="w_part or 'width: 17%;'">
Part Number<span class="fp-fr">N° de pièce</span>
</th>
<th t-att-style="w_po or 'width: 11%;'">
PO<span class="fp-fr">B/C</span>
</th>
<th t-att-style="w_wo or 'width: 11%;'">
WO<span class="fp-fr">B/T</span>
</th>
<th t-att-style="w_process or 'width: 14%;'">
Process<span class="fp-fr">Procédé</span>
</th>
<th class="text-start" t-att-style="w_desc or 'width: 23%;'">
Description
</th>
</tr>
</thead>
<tbody>
<t t-foreach="lines" t-as="line">
<t t-set="ordered_qty" t-value="line.product_uom_qty or 0.0"/>
<t t-set="wo_job" t-value="line.env['fp.job'].sudo().search([('sale_order_line_ids', 'in', line.ids)], limit=1)"/>
<t t-set="done_qty" t-value="(wo_job.qty_done if wo_job and wo_job.qty_done else ordered_qty)"/>
<t t-set="bo_qty" t-value="ordered_qty - done_qty if ordered_qty &gt; done_qty else 0.0"/>
<t t-set="proc_variant" t-value="(line.x_fc_process_variant_id if 'x_fc_process_variant_id' in line._fields else False)"/>
<t t-set="proc_label" t-value="(proc_variant.variant_label or proc_variant.name) if proc_variant else ((line.x_fc_part_catalog_id.default_process_id.variant_label or line.x_fc_part_catalog_id.default_process_id.name) if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id else '')"/>
<tr>
<td class="fp-ps-num">
<span t-esc="int(ordered_qty) if ordered_qty == int(ordered_qty) else ordered_qty"/>
</td>
<td class="fp-ps-num">
<span t-esc="int(done_qty) if done_qty == int(done_qty) else done_qty"/>
</td>
<td class="fp-ps-num">
<span t-esc="int(bo_qty) if bo_qty == int(bo_qty) else bo_qty"/>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</td>
<td class="fp-ps-num">
<span t-esc="po_number or '-'"/>
</td>
<td class="fp-ps-num">
<t t-if="wo_job"><span t-esc="wo_job.name"/></t>
<t t-else="">-</t>
</td>
<td class="fp-ps-num">
<span t-esc="proc_label or '-'"/>
</td>
<td>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
</tr>
</t>
</tbody>
</table>
</template>
<template id="report_fp_packing_slip_delivery_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
<!-- Model-agnostic resolution. This ONE template backs
four report actions (sale.order, fp.job,
fp.receiving, fusion.plating.delivery) so the
packing slip prints from any of those screens.
Resolve the sale order + ship-to per doc type, then
render a common layout from the SO lines. (Template
id kept as "...delivery..." for back-compat with the
action + Python that reference it.) -->
<t t-set="m" t-value="doc._name"/>
<t t-set="_so" t-value="False"/>
<t t-if="m == 'sale.order'">
<t t-set="_so" t-value="doc"/>
</t>
<t t-elif="m == 'fp.job' or m == 'fp.receiving'">
<t t-set="_so" t-value="doc.sale_order_id"/>
</t>
<t t-elif="m == 'fusion.plating.delivery'">
<t t-set="_dlv_job" t-value="env['fp.job'].sudo().search([('name', '=', doc.job_ref)], limit=1) if doc.job_ref else env['fp.job']"/>
<t t-set="_so" t-value="_dlv_job.sale_order_id if _dlv_job else False"/>
</t>
<t t-set="_lines" t-value="_so.order_line.filtered(lambda l: l.product_id and not l.display_type and l.product_uom_qty &gt; 0) if _so else False"/>
<t t-set="ship_partner" t-value="(doc.delivery_address_id or doc.partner_id) if m == 'fusion.plating.delivery' else ((_so.partner_shipping_id or _so.partner_id) if _so else doc.partner_id)"/>
<t t-set="bill_partner" t-value="(_so.partner_invoice_id if _so and _so.partner_invoice_id else (ship_partner.commercial_partner_id or ship_partner))"/>
<t t-set="has_carrier" t-value="m == 'fusion.plating.delivery' and 'x_fc_carrier_id' in doc._fields and doc.x_fc_carrier_id"/>
<t t-set="ship_via" t-value="(doc.x_fc_carrier_id.name if has_carrier else (_so.x_fc_ship_via if _so and 'x_fc_ship_via' in _so._fields and _so.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
<t t-set="tracking_text" t-value="'Ready for pick up' if not has_carrier else '—'"/>
<t t-set="po_number" t-value="(_so.client_order_ref if _so and _so.client_order_ref else '')"/>
<t t-set="_scheduled" t-value="doc.scheduled_date if m == 'fusion.plating.delivery' else (doc.commitment_date if m == 'sale.order' else (_so.commitment_date if _so else False))"/>
<t t-set="_notes" t-value="doc.notes if m == 'fusion.plating.delivery' else (doc.note if m == 'sale.order' else False)"/>
<t t-set="so_name_raw" t-value="_so.name if _so else (doc.name or '')"/>
<t t-set="ps_number" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
<t t-set="qr_payload" t-value="doc.name or ''"/>
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
<div class="fp-report fp-ps">
<div class="page">
<div class="fp-ps-titlebar">
<t t-if="ps_barcode_uri">
<div class="fp-ps-barcode">
<div class="fp-bc-wrap">
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
</div>
</div>
</t>
<span class="fp-ps-title-en">
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
</span>
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
</div>
<table class="bordered fp-ps-addrtable">
<tbody>
<tr>
<td style="width: 50%;">
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
<t t-set="label" t-value="'Bill To:'"/>
<t t-set="partner" t-value="bill_partner"/>
</t>
</td>
<td style="width: 50%;">
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
<t t-set="label" t-value="'Ship To:'"/>
<t t-set="partner" t-value="ship_partner"/>
</t>
</td>
</tr>
</tbody>
</table>
<table class="bordered fp-ps-info-table">
<thead>
<tr>
<th style="width: 33%;">
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
</th>
<th style="width: 33%;">
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
</th>
<th style="width: 34%;">
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td><span t-esc="ship_via"/></td>
<td>
<t t-if="_scheduled">
<span t-out="_scheduled" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""></t>
</td>
<td><span t-esc="tracking_text"/></td>
</tr>
</tbody>
</table>
<t t-if="_lines">
<t t-call="fusion_plating_reports.fp_packing_slip_items_lines">
<t t-set="lines" t-value="_lines"/>
</t>
</t>
<t t-else="">
<p style="margin-top: 10px; color: #555;">
No order lines found for this document
(<span t-esc="doc.name or ''"/>).
</p>
</t>
<t t-if="_notes">
<div style="margin-top: 10px;">
<strong>Notes:</strong>
<div t-out="_notes"/>
</div>
</t>
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
</div>
</div>
</t>
</t>
</t>
</template>
<record id="action_report_fp_packing_slip_delivery_portrait" model="ir.actions.report">
<field name="name">Packing Slip</field>
<field name="model">fusion.plating.delivery</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="fusion_plating_logistics.model_fusion_plating_delivery"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<!-- Same packing slip, exposed in the Print menu of the Sale Order,
Work Order (fp.job) and Receiving/Shipping (fp.receiving) screens.
All reuse the model-agnostic template above. -->
<record id="action_report_fp_packing_slip_so_portrait" model="ir.actions.report">
<field name="name">Packing Slip</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<record id="action_report_fp_packing_slip_job_portrait" model="ir.actions.report">
<field name="name">Packing Slip</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<record id="action_report_fp_packing_slip_receiving_portrait" model="ir.actions.report">
<field name="name">Packing Slip</field>
<field name="model">fp.receiving</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="fusion_plating_receiving.model_fp_receiving"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
</odoo> </odoo>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.37.1.0', 'version': '19.0.37.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """ 'description': """
@@ -79,6 +79,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss', 'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml', 'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js', 'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
# Confirm-with-preview dialog (reuse saved Plating Signature on sign-off)
'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js',
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss', 'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml', 'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js', 'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',

View File

@@ -240,6 +240,11 @@ class FpWorkspaceController(http.Controller):
return { return {
'ok': True, 'ok': True,
'user_has_plating_signature': bool(env.user.x_fc_signature_image),
'user_plating_signature': (
('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode())
if env.user.x_fc_signature_image else ''
),
'job': { 'job': {
'id': job.id, 'id': job.id,
'name': job.name, 'name': job.name,
@@ -448,37 +453,35 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/sign_off — capture signature + finish step atomically # /fp/workspace/sign_off — capture signature + finish step atomically
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri): def sign_off(self, step_id, signature_data_uri=None):
env = request.env env = request.env
sig = (signature_data_uri or '').strip()
if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
return {
'ok': False,
'error': 'A signature is required to finish this step.',
}
step = env['fp.job.step'].browse(int(step_id)) step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'} return {'ok': False, 'error': f'Step {step_id} not found'}
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it) 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:'): if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1] sig = sig.split(',', 1)[1]
try: try:
env['ir.attachment'].create({ user.write({'x_fc_signature_image': sig})
'name': f'signature_{step.id}.png',
'datas': sig,
'res_model': 'fp.job.step',
'res_id': step.id,
'mimetype': 'image/png',
})
except Exception: except Exception:
_logger.exception( _logger.exception(
"workspace/sign_off: attachment failed for step %s", step.id, "workspace/sign_off: persisting Plating Signature failed for uid %s",
env.uid,
) )
return {'ok': False, 'error': 'Failed to save signature.'} 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: try:
step.button_finish() step.button_finish()
@@ -487,11 +490,7 @@ class FpWorkspaceController(http.Controller):
return {'ok': False, 'error': str(exc)} return {'ok': False, 'error': str(exc)}
_logger.info("Step %s signed off by uid %s", step.id, env.uid) _logger.info("Step %s signed off by uid %s", step.id, env.uid)
return { return {'ok': True, 'step_id': step.id, 'state': step.state}
'ok': True,
'step_id': step.id,
'state': step.state,
}
# ====================================================================== # ======================================================================
# /fp/workspace/advance_milestone — fire next_milestone_action # /fp/workspace/advance_milestone — fire next_milestone_action

View File

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

View File

@@ -25,6 +25,7 @@ import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip"; import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz"; import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad"; import { FpSignaturePad } from "./components/signature_pad";
import { FpSignatureConfirm } from "./components/signature_confirm";
import { FpHoldComposer } from "./components/hold_composer"; import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock"; import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog"; import { FpRackPartsDialog } from "./rack_parts_dialog";
@@ -38,7 +39,7 @@ import { FileModel } from "@web/core/file_viewer/file_model";
export class FpJobWorkspace extends Component { export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace"; static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"]; static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog }; static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
@@ -363,14 +364,41 @@ export class FpJobWorkspace extends Component {
async onFinishStep(step) { async onFinishStep(step) {
if (step.requires_signoff) { if (step.requires_signoff) {
if (this.state.data.user_has_plating_signature) {
// One-tap confirm with a preview of the saved Plating Signature.
this.dialog.add(FpSignatureConfirm, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
signatureUrl: this.state.data.user_plating_signature,
onConfirm: () => this._commitSignOff(step, null), // use saved sig
onRedraw: () => this._openSignaturePad(step), // draw a new one
});
} else {
// First time — draw once; the backend persists it to the
// user's Plating Signature so later sign-offs are one-tap.
this._openSignaturePad(step);
}
return;
}
// Plain finish — route through /fp/workspace/finish_step which
// returns structured errors so we can show the FpFinishBlockDialog
// for required-inputs failures (with manager bypass option).
await this._callFinishStep(step, /* bypass */ false);
}
_openSignaturePad(step) {
this.dialog.add(FpSignaturePad, { this.dialog.add(FpSignaturePad, {
title: `Sign to finish ${step.name}`, title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => { onSubmit: (dataUri) => this._commitSignOff(step, dataUri),
});
}
async _commitSignOff(step, dataUri) {
try { try {
const res = await fpRpc("/fp/workspace/sign_off", { const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id, step_id: step.id,
signature_data_uri: dataUri, signature_data_uri: dataUri, // null -> backend uses the saved signature
}); });
if (res && res.ok) { if (res && res.ok) {
this.notification.add("Step signed off and finished.", { type: "success" }); this.notification.add("Step signed off and finished.", { type: "success" });
@@ -381,14 +409,6 @@ export class FpJobWorkspace extends Component {
} catch (err) { } catch (err) {
this.notification.add(err.message, { type: "danger" }); this.notification.add(err.message, { type: "danger" });
} }
},
});
return;
}
// Plain finish — route through /fp/workspace/finish_step which
// returns structured errors so we can show the FpFinishBlockDialog
// for required-inputs failures (with manager bypass option).
await this._callFinishStep(step, /* bypass */ false);
} }
async _callFinishStep(step, bypassRequiredInputs) { async _callFinishStep(step, bypassRequiredInputs) {

View File

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

View File

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

View File

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

View File

@@ -1,799 +0,0 @@
# fusion_schedule — Claude Code Instructions
> Module-level guide. The repo-wide Odoo 19 rules in `K:\Github\Odoo-Modules\CLAUDE.md`
> (and the global `K:\Github\CLAUDE.md`) **still apply** — this file only adds what is
> specific to `fusion_schedule`. Read both.
>
> **Companion docs:** [`CODE_MAP.md`](CODE_MAP.md) is the precise symbol-level
> "where-is-what" index (every field/method/route/JS fn/template with line numbers) — use it
> to locate code; use this file for guidance. Open audit findings are tracked in Supabase
> `fusionapps.issues` under project **Fusion Schedule**
> (`576de219-57e6-4596-8c8c-0c093e4cb54a`) and summarised in §16 below.
>
> **Provenance:** this module was originally designed & coded with **Cursor using Claude 4.5
> Opus** (AI-generated), then audited by Claude Code. That shows in the failure profile: the
> Odoo-19 *syntax/idioms* are clean (no deprecated APIs), but the bugs cluster in semantic areas
> that need domain reasoning or a running install to catch — unscoped ORM queries (cross-user
> event merging), timezone handling, copy-paste-drifted duplicates (authenticated vs public
> booking), swallowed exceptions, and untested public/render paths. When extending it, **assume
> plausible-but-unverified until tested on Enterprise.**
---
## 1. What this module is
**Fusion Schedule** (`fusion_schedule`, `__manifest__.py` version **19.0.2.1.0**, author
"Fusion Claims", LGPL-3) is a **multi-account calendar synchronisation hub + portal
booking system** for staff (authorizers / sales reps / technicians) in the Fusion Claims
product family.
Three product surfaces, one engine:
1. **Multi-calendar sync** — a staff user connects any number of **Google** and **Microsoft
Outlook** calendars. A 5-minute cron pulls external events into Odoo `calendar.event`
and pushes Odoo-native events out, so the user has one merged calendar and is "busy on
one → blocked on all".
2. **Portal "My Schedule"** (`/my/schedule`) — a portal dashboard: today's + upcoming
appointments, connected-account management, schedule preferences (work hours / break /
travel buffer / base address), a booking form with a week-calendar preview, **AI slot
suggestions** and **AI day-route optimization**, and travel-time blocking.
3. **Public booking links** (`/schedule/<slug>`) — each user gets a shareable slug; external
visitors (no login) can self-book into the user's free slots and later
cancel/reschedule via a per-event **manage token** (`/schedule/manage/<token>`).
> ⚠️ This is the active **Outlook ↔ Odoo sync** for this deployment — **not** Odoo's native
> `microsoft_calendar`/`google_calendar` sync. The backend calendar UI patch (see §11)
> deliberately **hides** the native sync buttons and substitutes Fusion Schedule's own.
It was originally built in **Cursor** (note the leftover `graphify-out/` artifact — a Cursor
code-graph dump; safe to ignore/delete, not loaded by Odoo). Development now happens in
Claude Code.
---
## 2. Enterprise-only — you cannot install this on local Community
The manifest depends on **`appointment`** (Odoo **Enterprise**), plus `google_account` and
`microsoft_account`. Therefore — like `fusion_portal` and `fusion_repairs` — **it cannot be
installed or tested on local `odoo-modsdev` (Community).** The old
`-d fusion-dev -u <module>` recipe does **not** work here.
Test on an Enterprise environment (a Westin clone is the natural choice since
`fusion_portal` already runs there — see the *Westin Prod* section of the repo `CLAUDE.md`).
There are currently **no automated tests** in this module (`tests/` does not exist).
---
## 3. Dependency map
### 3.1 Hard dependencies (`__manifest__.py` → `depends`)
```
base · portal · website · calendar · appointment · google_account · microsoft_account · fusion_portal
```
- `appointment` — Enterprise. Uses `appointment.type`, `appointment.invite`, and
`appointment_type._prepare_calendar_event_values(...)` to build booking events.
- `calendar` — the core model everything revolves around (`calendar.event` is inherited).
- `google_account` / `microsoft_account` — base OAuth plumbing. **Note:** the module rolls
its *own* OAuth flow (it does not reuse `google_calendar`/`microsoft_calendar` sync). It
only borrows their stored client-id ICP params as a *fallback* (see §10).
- `fusion_portal` — the **only `fusion_*` hard dependency**. This is what transitively pulls
in the whole claims stack: `fusion_portal → fusion_claims` (+ `fusion_tasks`,
`fusion_loaners_management`, `knowledge`). So **`fusion_claims` is a transitive
dependency**, always present at runtime.
### 3.2 Soft dependencies (used via `try/except`, NOT in `depends`)
- **`fusion_api`** (`fusion.api.service`) — preferred broker for the Google Maps key and
OpenAI calls. Not declared in `depends`; every call is wrapped in `try/except` and falls
back to `fusion_claims.*` ICP params, then degrades gracefully. The module still runs if
`fusion_api` is absent.
### 3.3 Reverse dependencies
- **Nothing depends on `fusion_schedule`.** It is a leaf/top module. The only mention
elsewhere is `fusion_repairs/__manifest__.py` which lists "fusion_schedule slots" as a
*deferred / future* integration — not a real dependency today.
```
┌─────────────────┐
│ fusion_schedule │ (leaf — nothing depends on it)
└────────┬────────┘
depends │ soft (try/except, NOT in manifest)
┌─────────────────┼──────────────────────────┐
▼ ▼ ▼
fusion_portal appointment (EE) fusion_api ── fusion.api.service
│ google_account / microsoft_account (Maps key + OpenAI broker)
fusion_claims ── owns the `fusion_claims.*` ICP params reused as fallbacks
│ (+ fusion_tasks, fusion_loaners_management, knowledge)
```
---
## 4. ⭐ Relationship with `fusion_claims` (read this — it's the whole point of the coupling)
`fusion_schedule` **does not modify any `fusion_claims` model or view.** The coupling is
indirect and entirely through shared infrastructure. Five concrete links:
### 4.1 Transitive dependency (stack position)
`fusion_schedule` sits **on top of** the claims stack via `fusion_portal → fusion_claims`.
It assumes the claims/portal data model and the authorizer/sales-rep portal already exist.
### 4.2 Config-parameter namespace reuse (the main runtime link)
The portal pages **borrow `fusion_claims`-owned `ir.config_parameter` values** so the
schedule UI matches the claims portal branding and shares the same API keys. These params
are **defined in `fusion_claims/models/res_config_settings.py`**, *not* here:
| ICP key (owned by fusion_claims) | Used in fusion_schedule for | Where |
|---|---|---|
| `fusion_claims.portal_gradient_start` / `_mid` / `_end` | portal header gradient (brand colour) | `PortalSchedule._get_schedule_values()` |
| `fusion_claims.google_maps_api_key` | Maps/Places/Distance-Matrix key **fallback** | `_get_maps_api_key()` |
| `fusion_claims.ai_api_key` | OpenAI key **fallback** (direct HTTP) | `_call_ai()` |
> If you rename/remove these in `fusion_claims`, the schedule portal silently loses its
> gradient / maps / AI. They are read with defaults, so it won't crash — it just degrades.
### 4.3 The `fusion.api.service` broker (preferred path, fusion_claims-family convention)
`_get_maps_api_key()` and `_call_ai()` first try `request.env['fusion.api.service']`
(from **`fusion_api`**) — the same metered, budget-/rate-limited broker the rest of the
Fusion family uses — with `consumer='fusion_schedule'`. Only if that raises do they fall
back to the `fusion_claims.*` ICP params above. So the order is:
**`fusion_api` broker → `fusion_claims` ICP param → graceful no-op.**
> Two non-obvious facts (detail in [`CODE_MAP.md`](CODE_MAP.md) §9): (1) `get_api_key` returns
> the `group_admin`-gated `key.api_key` on a **non-sudo** recordset, so from a portal/public
> request it likely raises `AccessError` and the **ICP fallback fires every time** — for portal
> callers `fusion_claims.google_maps_api_key` is effectively the real source, not the broker.
> (2) That maps-key param is actually **owned by `fusion_tasks`** (`res_config_settings.py:12`),
> not `fusion_claims`, despite the `fusion_claims.*` prefix — grepping `fusion_claims/` for it
> finds nothing.
### 4.4 Portal tile injection (into fusion_portal, which is built on fusion_claims)
`views/portal_schedule_tile.xml` (`portal_my_home_schedule`, priority 45) inherits
**`fusion_portal.portal_my_home_authorizer`** (which itself inherits `portal.portal_my_home`,
priority 40) and `xpath`s a "My Schedule" card into the authorizer/sales-rep portal home
grid. It reuses fusion_portal's `fc_gradient` template var.
- **`fc_gradient` origin:** set in `fusion_portal/views/portal_templates.xml` as
`portal_gradient or <default green/blue>`, where `portal_gradient` is computed by
fusion_portal's home controller from the same `fusion_claims.portal_gradient_*` params
(§4.2). The tile falls back to the literal default if `fc_gradient` is unset.
- **⚠ Fragile xpath:** the tile anchors on
`//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]`.
If fusion_portal renames the funding-claims route, removes that card, or restructures the
home grid's classes, the tile **silently disappears** (or the view fails to load on `-u`).
Re-check this xpath whenever fusion_portal's home template changes.
### 4.5 The `tz` cookie is populated by fusion_portal
Fusion Schedule's timezone resolution (`_resolve_timezone`) reads a browser **`tz`** cookie
(IANA name). That cookie is set by **`fusion_portal/static/src/js/timezone_detect.js`**
(`tz=<IANA>;path=/;max-age=1yr;SameSite=Lax`) — and, redundantly, by Fusion Schedule's own
booking JS (`setTzCookie` IIFE). So on portal pages the correct timezone flows in **from
fusion_portal**; without that cookie (or a `user.tz`), times fall back to the company
calendar tz, then UTC.
### 4.5 Parallel/overlapping scheduling — they share the `calendar.event` table
`fusion_claims` already has its **own, simpler** scheduling:
- `fusion_claims.schedule.assessment.wizard` (`wizard/schedule_assessment_wizard.py`) — a
*backend* wizard that creates a plain `calendar.event` for an ADP assessment from a
`sale.order` (optional 1-day email alarm). No sync, no portal, no travel logic.
- `technician_task` routing — push notifications + travel time using the **same**
`fusion_claims.google_maps_api_key`.
`fusion_schedule` is the **newer, richer, portal-facing + multi-calendar layer**. Both
write to `calendar.event`, so they **interplay**: an assessment event created by the
fusion_claims wizard for a user who has connected calendars will be picked up by Fusion
Schedule's **cross-calendar push** (it's an unlinked `calendar.event` on the user's partner)
and mirrored to that user's external calendar, and it appears in `/my/schedule`. They are
**complementary, not isolated** — keep that shared table in mind when changing either side.
---
## 5. Data model
All custom fields use the `x_fc_*` prefix (repo convention). Models load in this order
(`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link →
calendar_event → res_users → res_config_settings`.
### 5.1 `fusion.calendar.account` — the OAuth account + sync engine *(god object, ~35 edges)*
`models/fusion_calendar_account.py`. One row per connected external calendar.
| Field | Notes |
|---|---|
| `x_fc_user_id` (m2o res.users, required, cascade) | owner |
| `x_fc_provider` (sel: google/microsoft, required) | |
| `x_fc_email` / `x_fc_name` (compute, stored) | label = "Google — a@b.com" |
| `x_fc_active` (bool) | |
| `x_fc_rtoken` / `x_fc_token` / `x_fc_token_validity` | **`groups='base.group_system'`** — OAuth secrets, admin-only |
| `x_fc_sync_token` | provider delta/sync token (`group_system`). Clear it to force a fresh full sync |
| `x_fc_calendar_id` (default `'primary'`) | |
| `x_fc_last_sync`, `x_fc_sync_status` (active/error/paused), `x_fc_error_message` | |
| `x_fc_link_ids` (o2m → event link) | |
This file is the engine. Key method groups (all on the account record):
- **Credential resolution** `_get_google_client_id/_secret`, `_get_microsoft_*` — dedicated
`fusion_schedule_*` ICP param → native `google_calendar_client_id` / `microsoft_calendar_client_id`.
- **Token mgmt** `_get_valid_token` (1-min skew buffer), `_refresh_token`
`_refresh_google_token` / `_refresh_microsoft_token` (MS may rotate the refresh token —
it's re-saved). On HTTP 400/401 the account is marked `error` and tokens cleared.
- **Code exchange** `_exchange_google_code` / `_exchange_microsoft_code` (called from the
controller callback). `_fetch_google_email` / `_fetch_microsoft_email`.
- **Pull (external → Odoo)** `_sync_pull``_sync_pull_google` / `_sync_pull_microsoft`,
with `_google_request_with_retry` / `_microsoft_request_with_retry` (429/503 + connection
retry, capped). Google initial window **now-14d … now+30d**; subsequent syncs use the
sync token (HTTP 410 → drop token, full resync). MS uses Graph `calendarView/delta`;
delta token expiry (`fullSyncRequired`/`SyncStateNotFound`) → full resync. MS page cap:
2000 events initial / 5000 incremental.
- **Event mapping** `_google_event_to_odoo_vals` / `_microsoft_event_to_odoo_vals` and the
reverse `_odoo_event_to_google` / `_odoo_event_to_microsoft`.
- **Upsert/dedup** `_process_google_event` / `_process_microsoft_event`,
`_find_existing_event` (matches name+start+stop, **includes archived** to reuse), and
`_upsert_event_link`.
- **Push (Odoo → external)** `_sync_push_event` (+ insert/patch/delete per provider).
- **Cross-calendar busy block** `_cross_calendar_push` (see §6.3).
- **Backend RPC** `get_user_accounts_status()`, `sync_current_user()` (called from the
calendar UI patch).
- **Cron** `_cron_sync_all_accounts()`.
- **Teardown** `action_disconnect()` — deletes pushed external events, unlinks rows, pauses.
### 5.2 `fusion.calendar.event.link` — Odoo-event ↔ external-event join
`models/fusion_calendar_event_link.py`. One row per (Odoo event, account).
- `x_fc_event_id` (m2o calendar.event, cascade), `x_fc_account_id` (m2o account, cascade),
`x_fc_external_id` (required), `x_fc_universal_id` (iCalUID — used for cross-provider
dedup), `x_fc_last_synced`, `x_fc_sync_direction` (pull/push/both).
- **Constraint:** `models.Constraint('UNIQUE(x_fc_account_id, x_fc_external_id)')` — an
external event links once per account. (Odoo-19 declarative constraint, per repo rule #9.)
### 5.3 `calendar.event` (inherited)
`models/calendar_event.py`. Adds:
- `x_fc_source_account_id` (m2o account) — set when an event was *pulled* from external;
used for colour-coding the source in the portal.
- `x_fc_is_external` (compute, **stored** from source account).
- `x_fc_link_ids` (o2m → link).
- `x_fc_manage_token` (indexed, `copy=False`) — 32-hex public manage token.
- `x_fc_client_email` / `x_fc_client_phone`.
- `x_fc_address_lat` / `x_fc_address_lng` (Float, digits 10,7) — for travel-time calc.
- `x_fc_travel_minutes_before` (int) and `x_fc_is_travel_block` (bool) — travel placeholder
events generated after booking.
- **`write()` / `unlink()` overrides** push updates/deletions to all linked external
calendars — **unless** `_skip_fc_sync()` is true (context has `no_calendar_sync` or
`dont_notify`). `write()` only pushes when a sync-relevant field changed.
### 5.4 `res.users` (inherited)
`models/res_users.py`. Adds per-staff scheduling config:
- `x_fc_calendar_account_ids` (o2m), `x_fc_schedule_slug` (**`UNIQUE` constraint**),
`x_fc_booking_enabled` (default False).
- Work prefs: `x_fc_work_start` (9.0), `x_fc_work_end` (17.0), `x_fc_break_start` (12.0),
`x_fc_break_duration` (0.5h), `x_fc_travel_buffer` (30 min), `x_fc_home_address` +
`x_fc_home_lat`/`x_fc_home_lng`.
- **`create()` override** auto-generates a slug from the name + 4-hex suffix
(`_generate_schedule_slug`). Every user (including pre-existing ones created elsewhere)
gets a unique public slug.
### 5.5 `res.config.settings` (inherited)
`models/res_config_settings.py`. See §12.
---
## 6. The sync engine — how events flow
### 6.1 Pull (external → Odoo), per account
1. `_get_valid_token()` (refresh if needed).
2. Fetch pages (sync-token delta when available, else the ±window).
3. For each event: cancelled/removed → archive local + unlink the link row; otherwise
**upsert** with a 3-tier dedup ladder:
- existing link for `(account, external_id)` → update in place;
- else existing link by **iCalUID** (cross-provider/same-event) → relink;
- else `_find_existing_event` by name+start+stop (incl. archived) → reuse + relink;
- else **create** a new `calendar.event` (owner partner attached) + new link.
4. Persist `x_fc_sync_token`, `x_fc_last_sync`, status.
### 6.2 Push (Odoo → external), per event
`calendar.event.write()` triggers `_sync_push_event` on each linked active account
(insert if no link, patch if linked). New links are tagged `direction='push'`.
### 6.3 Cross-calendar busy-blocking (`_cross_calendar_push`)
Runs in the cron **only for users with ≥2 active accounts**. It finds the user's
**Odoo-native** events (those with **no** existing link) in the window now-1d … now+90d and
pushes them to the **first active account only** (lowest id). Pushing to a single calendar +
only un-linked events together prevent the **pull → push → pull feedback loop** and
cross-calendar duplicates. *This is the "busy on one, blocked on all" mechanism.*
### 6.4 Cron
`data/ir_cron_data.xml``ir_cron_fusion_calendar_sync`, every **5 minutes**, runs as
`base.user_root`, code `model._cron_sync_all_accounts()`. Never-synced accounts are
processed first. Per-account isolation uses `self.env.cr.commit()` / `rollback()` so one bad
account doesn't poison the batch (see §13 footgun about tests).
---
## 7. OAuth connect/callback flow
`/my/schedule/connect/google` and `/connect/microsoft` build the auth URL (scopes:
Google `calendar` + `userinfo.email`, offline + consent; Microsoft `offline_access openid
Calendars.ReadWrite User.Read`), stash a CSRF token in `request.session['fc_oauth_csrf']`,
and encode `{provider, csrf}` into `state`. Redirect URI is always
`<web.base.url>/my/schedule/oauth/callback`.
`/my/schedule/oauth/callback` validates `state` + CSRF, exchanges the code, fetches the
account email, then **find-or-creates** a `fusion.calendar.account` (re-activating a matching
existing one). Requires a **refresh token** — if the provider didn't return one, it errors
asking the user to grant offline access. There's a resilience fallback:
`_find_recently_connected_account` (created in the last 10 min) so a refreshed/timed-out
callback still reports success instead of erroring.
---
## 8. Travel time + AI scheduling
- **Travel time** `_get_travel_time(lat,lng→lat,lng)` — Google **Distance Matrix** (driving,
avoid tolls, depart now), returns minutes or 0 on any failure. `_geocode_address` uses the
Geocoding API (region `ca`).
- **Travel blocks** `_create_travel_blocks(event, staff_user)` — after a booking, looks at
the prev/next located appointments that day and inserts `Travel to …` placeholder events
(`x_fc_is_travel_block=True`, `show_as=busy`) sized to `max(distance-matrix, travel_buffer)`.
- **AI slot suggest** `/my/schedule/ai/suggest` — builds a schedule context, asks OpenAI
(`gpt-4o-mini`) to pick **exactly 3** times **from the provided free-slot list only**
(strict prompt + post-filter against the real slots; never invents times). Used by the
booking form.
- **AI day optimize** `/my/schedule/ai/optimize` — needs ≥2 located appointments; builds a
travel matrix and asks OpenAI for an optimal visiting order + suggested times + savings.
- Both AI calls route through `_call_ai()` (`fusion.api.service.call_openai`
`fusion_claims.ai_api_key` direct-HTTP fallback). Failures degrade to "AI unavailable".
---
## 9. Routes (controllers/portal_schedule.py — `PortalSchedule(CustomerPortal)`)
| Method | Route | Auth | Renders / returns |
|---|---|---|---|
| http | `/my/schedule` | user | `portal_schedule_page` |
| jsonrpc | `/my/schedule/preferences` | user | save work/break/travel/home prefs (geocodes address) |
| http | `/my/schedule/book` | user | `portal_schedule_book` |
| jsonrpc | `/my/schedule/available-slots` | user | free slots for a date |
| jsonrpc | `/my/schedule/week-events` | user | MonSun events for the week strip |
| http POST | `/my/schedule/book/submit` | user | create booking (+ confirmation email + travel blocks) |
| jsonrpc | `/my/schedule/event/cancel` | user | delete own event |
| jsonrpc | `/my/schedule/event/reschedule` | user | move own event |
| jsonrpc | `/my/schedule/ai/suggest` | user | 3 AI slot picks |
| jsonrpc | `/my/schedule/ai/optimize` | user | AI day route |
| http | `/my/schedule/connect/google` · `/connect/microsoft` | user | start OAuth |
| http | `/my/schedule/oauth/callback` | user | finish OAuth |
| jsonrpc | `/my/schedule/disconnect` | user | `action_disconnect` |
| jsonrpc | `/my/schedule/sync-now` | user | `_sync_pull` one account |
| jsonrpc | `/my/schedule/toggle-booking` | user | enable/disable public page |
| http | `/schedule/<slug>` | **public** | `public_booking_page` |
| jsonrpc | `/schedule/<slug>/available-slots` | **public** (csrf=False) | slots |
| http POST | `/schedule/<slug>/book` | **public** (csrf) | public booking |
| http | `/schedule/manage/<token>` | **public** | `public_manage_page` |
| http POST | `/schedule/manage/<token>/cancel` · `/reschedule` | **public** (csrf) | self-service |
| jsonrpc | `/schedule/manage/<token>/available-slots` | **public** (csrf=False) | slots |
Backend (ORM, not HTTP), called from the calendar UI patch:
`fusion.calendar.account.get_user_accounts_status()` and `.sync_current_user()`.
**Slot generation** (`_generate_available_slots`) is the shared core for *all* slot
endpoints: honours the staff user's work hours / break / travel-buffer, intersects with
appointment-type recurring slots, removes past times, and rejects slots that overlap any
existing event **plus the travel buffer** after it.
**Timezone resolution** (`_resolve_timezone`): `user.tz``tz` cookie (set by the frontend
JS / fusion_portal, §4.5) → `company.resource_calendar_id.tz` → UTC.
### 9.1 Authenticated portal vs public booking are TWO separate implementations
This is the single most important structural fact the templates reveal — the two booking
flows do **not** share code and behave differently:
| | Authenticated `/my/schedule/book` | Public `/schedule/<slug>` |
|---|---|---|
| Layout | `portal.portal_layout` (portal chrome + breadcrumbs) | `website.layout` (public site chrome) |
| Slot/booking JS | the **registered asset files** (`portal_schedule_booking.js`, `portal_schedule_accounts.js`) | **inline `<script>`** embedded in `public_booking.xml` (a *second copy* of the slot-render + Places-autocomplete logic) |
| Brand gradient | `portal_gradient` from `fusion_claims.*` params | **hardcoded** `linear-gradient(135deg,#5ba848,#3a8fb7)` — ignores the brand params |
| Event creation | `appointment_type._prepare_calendar_event_values(...)` → a real **appointment** with booking lines/capacity | a **raw `calendar.event`** dict (no appointment lines, no capacity) |
| Slot re-validation on submit | **yes** — re-runs `_generate_available_slots` and rejects stale slots | **no** — trusts the posted `slot_datetime` (double-book risk) |
| Week-calendar preview + AI suggest/optimize | yes | no |
So "fix the booking form" almost always means **edit two places**. Changing slot logic in
the Python `_generate_available_slots` covers both (it's shared server-side), but any
client-side change to slot rendering, autocomplete, or validation must be mirrored between
`portal_schedule_booking.js` and the inline script in `public_booking.xml`.
### 9.2 Two share links, one of them dead
- `schedule_page` computes `share_url = appointment.invite.book_url` (native appointment
share, looked up by `staff_user_ids`) **and** `public_booking_url = <base>/schedule/<slug>`.
Only **`public_booking_url`** is actually rendered (the "Share Booking Link" card/button).
`share_url` is passed to the template but **never used** — and the only seeded
`appointment.invite` (`default_appointment_invite`) has empty `appointment_type_ids`/no
staff, so it would be blank anyway. The slug link is the real share mechanism.
- There is **no `_prepare_home_portal_values` override**, so `/my/schedule` has **no portal
home counter** and no portal breadcrumb registration — the injected tile (§4.4) is the
only discoverable entry point besides the calendar-view cog button (§11).
---
## 10. ICP parameters (full list)
**Owned by this module:**
- OAuth creds: `fusion_schedule_google_client_id`, `fusion_schedule_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `fusion_schedule_microsoft_client_secret`
- Sync: `fusion_schedule_sync_interval` (minutes; **note:** the cron interval is set in XML,
this param is currently informational — changing it does not re-write the cron)
- Defaults: `fusion_schedule.default_work_start` / `_work_end` / `_break_start` /
`_break_duration` / `_travel_buffer`
**Fallbacks read from elsewhere (not owned here):**
- Native Odoo: `google_calendar_client_id`, `google_calendar_client_secret`,
`microsoft_calendar_client_id`, `microsoft_calendar_client_secret`, `web.base.url`
- **fusion_claims namespace:** `fusion_claims.portal_gradient_start/_mid/_end`,
`fusion_claims.google_maps_api_key`, `fusion_claims.ai_api_key` (see §4.2)
---
## 11. Frontend / assets
Registered in `__manifest__.py` `assets`:
**`web.assets_backend`** — patches the native calendar:
- `static/src/views/fusion_calendar_controller.js``patch(AttendeeCalendarController…)`:
loads connected accounts (`get_user_accounts_status`) and adds a "Sync now"
(`sync_current_user`) action.
- `static/src/views/fusion_calendar_controller.xml` — t-inherits
`calendar.AttendeeCalendarController`, **hides** `#header_synchronization_settings` (the
native Google/Outlook sync UI, kept in DOM so other xpaths survive) and injects Fusion's
account chips + sync button + a cog link to `/my/schedule`.
**`web.assets_frontend`** — portal pages:
- `static/src/css/portal_schedule.css`
- `static/src/js/portal_schedule_booking.js` — booking form: sets the `tz` cookie, week
calendar strip, slot fetch + morning/afternoon grouping, AI suggestions, **Google Places
address autocomplete** (`country: 'ca'`, writes hidden lat/lng), submit guards.
- `static/src/js/portal_schedule_accounts.js` — the `/my/schedule` dashboard: reusable
`fusionConfirm` modal + `fusionToast`, disconnect/sync-now, share-link (Web Share /
clipboard), save-preferences, cancel/reschedule modals, AI "optimize my day" modal.
These are **plain IIFE scripts** (not Odoo `Interaction` classes) that bind to **DOM element
IDs** in the QWeb templates. If you rename an element id in the templates you must update the
JS, and vice-versa. Key ids the JS expects: `bookingDate`, `appointmentTypeSelect`,
`slotsContainer/slotsGrid/slotsLoading/noSlots`, `slotDatetime`, `slotDuration`,
`weekCalendar*`, `aiSuggest*`, `clientStreet/clientCity/clientProvince/clientPostal/clientLat/clientLng`,
`rescheduleModal` (+ children), `optimizeModal` (+ children), `schedulePrefsForm`,
`fusionConfirmModal`.
**Templates** (QWeb):
- `views/portal_schedule.xml``portal_schedule_page`, `portal_schedule_book`
(both `portal.portal_layout`).
- `views/public_booking.xml``public_booking_page`, `public_manage_page`
(both `website.layout`; **carry their own inline `<script>`** — see §9.1).
- `views/portal_schedule_tile.xml``portal_my_home_schedule` (the fusion_portal tile).
Frontend wiring notes:
- **Google Maps loader handshake.** The booking templates inject the Maps Places script with
`&callback=initScheduleAddressAutocomplete` (public: `initPublicAddressAutocomplete`). Because
the async script can land before *or* after the IIFE in `portal_schedule_booking.js`, they
coordinate via `window._googleMapsReady` / `window._scheduleAutocompleteInit`. Maps only
loads when a `google_maps_api_key` resolved (§4.2/§4.3) — no key ⇒ no autocomplete, fields
still work manually.
- **Dead toast markup.** `portal_schedule.xml` ships a Bootstrap `#fusionToast` /
`#fusionToastMessage` element, but `portal_schedule_accounts.js` defines its own
`fusionToast()` that builds a fresh `#fusionToastLive` node and **ignores** the template
one. Don't wire new code to `#fusionToast`; call the JS `fusionToast(msg, type)` helper.
- **CSS** (`portal_schedule.css`) is tiny: collapse-chevron rotation, a `.min-width-0`
truncation helper, and mobile sizing for slot buttons / tables / modals. No theming —
colours come from the inline `portal_gradient` styles and Bootstrap utility classes.
---
## 12. Settings UI
`views/res_config_settings_views.xml` adds a **"Fusion Schedule"** app block to
Settings (`base.res_config_settings_view_form`, priority 90) with: Sync Interval, Google
OAuth creds (+ "using Odoo default" hint via `x_fc_google_has_fallback`), Microsoft OAuth
creds (+ fallback hint), and Schedule Defaults (work hours / break / travel buffer, all
`float_time` widgets). The compute fields `x_fc_*_has_fallback` light up when no dedicated
key is set but a native `*_calendar_client_id` exists.
Backend list/form for accounts: `views/fusion_calendar_account_views.xml`
action + menu **Settings → Technical → Calendar Accounts** (`base.menu_custom`).
---
## 13. Security
`security/security.xml` — two record rules (both additive on `base.group_user`):
- users see only their own `fusion.calendar.account` (`x_fc_user_id = user.id`);
- users see only event links for their own accounts.
`security/ir.model.access.csv` — account: full CRUD for `group_user`, none for
`group_public`; event link: CRU for `group_user`, full for `group_system`.
OAuth secrets (`x_fc_rtoken/x_fc_token/x_fc_token_validity/x_fc_sync_token`) are
`groups='base.group_system'` so non-admin users can't read them even on their own rows;
sync code uses `.sudo()` to access them.
---
## 14. Footguns & gotchas (read before editing)
1. **The silent-context flags are load-bearing.** Any time you create/write/unlink a
`calendar.event` *during sync or travel-block creation*, pass `_silent_ctx()` (or at
least `no_calendar_sync=True, dont_notify=True`). Otherwise the `calendar.event`
`write/unlink` overrides will try to **push back to external calendars** → pull → push
feedback loop and/or attendee emails. The whole sync path already does this; mirror it.
2. **MS delta `@removed` reason matters.** `@removed` with reason `'deleted'` (or
`isCancelled`) → archive + unlink. `@removed` with any other reason (typically
`'changed'`) → **return `'skipped'`, do NOT archive** — the event merely drifted out of
the delta window and still exists upstream. This exact distinction was the
`f1cea2fb` bug fix ("stop archiving valid events on @removed=changed"). Don't regress it.
3. **`cr.commit()` / `cr.rollback()` in the cron will raise inside `TransactionCase`.**
Per repo rule #14, Odoo 19 test cursors refuse commit/rollback. There are no tests today,
but if you add any that exercise `_cron_sync_all_accounts` / `sync_current_user`, refactor
to `with self.env.cr.savepoint():` per iteration instead of commit/rollback, or the test
cursor will break.
4. **Declarative SQL objects only** (rule #9): this module already uses
`models.Constraint(...)` for the unique constraints — keep that style, never
`_sql_constraints` or `init()`.
5. **`google_account`/`microsoft_account` ≠ native calendar sync.** Don't "simplify" by
reusing `google_calendar`/`microsoft_calendar` sync — this module intentionally owns its
OAuth + sync and hides the native UI. The native client-id params are only a credential
fallback.
6. **Public endpoints.** `/schedule/<slug>` and `/schedule/manage/<token>` are
`auth='public'`. The manage token is `secrets.token_hex(16)` (32 chars) and
`_get_event_by_token` enforces `len == 32`. Public booking requires both
`x_fc_booking_enabled=True` **and** the user having an `appointment.type` with them as
staff. Keep CSRF on the POST forms; the slot JSON-RPC endpoints are `csrf=False` by design.
7. **`data/appointment_invite_data.xml` is `noupdate=1`** and ships
`default_appointment_invite` with **empty** `appointment_type_ids` — the generic
`/book/book-appointment` share link won't resolve to a real type until configured. The
`/my/schedule` page separately resolves an `appointment.invite` by `staff_user_ids`.
8. **`data/mail_template_data.xml` is NOT `noupdate`** — the booking confirmation template
(`fusion_schedule_booking_confirmation`, on `calendar.event`) reloads on every `-u`.
It renders the manage link from `company.website or get_base_url()`.
9. **`graphify-out/` is a Cursor artifact**, not part of the module. It's not in the
manifest and Odoo never loads it. Safe to ignore or delete; don't treat its
`GRAPH_REPORT.md` as authoritative (it's a heuristic code-graph, ~87% extracted).
10. **Soft-dependency discipline.** Never assume `fusion_api` is installed — keep the
`try/except` + ICP fallback pattern in `_get_maps_api_key` / `_call_ai`. Adding
`fusion_api`/`fusion_claims` to `depends` would change the install graph; only do it
deliberately.
11. **Public booking does NOT re-validate the slot.** `schedule_book_submit` (authenticated)
re-runs `_generate_available_slots` and rejects a slot that's no longer free;
`public_book_submit` does **not** — it trusts the posted `slot_datetime`. Two visitors
hitting the same public slot can double-book. If you tighten this, add the same
re-validation to the public path.
12. **The two booking flows diverge** (§9.1): authenticated bookings are real `appointment`
events (`_prepare_calendar_event_values`); public bookings are raw `calendar.event`
rows. Reporting/automation that assumes every booking is an `appointment.type` booking
will miss public ones. Client-side changes must be made twice (asset file **and** the
inline script in `public_booking.xml`).
13. **`public_booking_page` references `today` but the controller never passes it.** The
template has `t-att-min="today"` on the date picker, yet
`PortalSchedule.public_booking_page()`'s values dict omits `today`. Either the website
render context happens to supply it or the `min` is silently empty (no past-date guard on
the public picker). **Verify / fix** by passing `today` from the controller if you touch
this page. (The authenticated book page correctly uses `now.strftime('%Y-%m-%d')`.)
14. **Public pages ignore the brand gradient.** They hardcode the default green/blue; only
the authenticated portal pages pick up `fusion_claims.portal_gradient_*`. If branding
must reach the public booking page, thread `portal_gradient` through
`public_booking_page` / `public_manage_page` values.
---
## 15. Deployment & history
- Built in **Cursor**; now maintained in Claude Code.
- Lives wherever **`fusion_portal`** lives (the authorizer/sales-rep portal — the **Westin**
Enterprise environment per the repo `CLAUDE.md` *Westin Prod* section). **Verify the
current target before shipping** — there's no in-module deploy note and nothing else
depends on it.
- Notable recent commits touching it:
- `f1cea2fb` — fix: stop archiving valid events on MS `@removed=changed` (the §14.2 bug).
- `747c8142``fusion_portal` renamed from `fusion_authorizer_portal` (this module's
`depends`/tile `inherit_id` already reference the **new** name `fusion_portal`).
- **Renaming the technical name** would require the full DB-rename procedure in repo rule #16
(it's a `fusion_*` module with external IDs, view keys, and a cron baked into the DB).
---
## 16. Audit findings — confirmed bugs, gaps & risks (2026-06-03 deep dive)
These were found by reading the code, not by running it. None are fixed yet — they're
recorded so the next change can address (or consciously accept) them. **The slot `datetime`
emitted by `_generate_available_slots` is UTC** (line 520: `slot_start_utc.strftime(...)`);
hold that fact while reading #1.
### 🔴 Bugs
1. **Timezone double-conversion on 3 of the 4 booking write-paths.** The slot's hidden
`datetime` is **UTC**, but only the authenticated *booking* path consumes it as UTC:
-`schedule_book_submit` (`portal_schedule.py:661`) — `datetime.strptime(...)` used
directly as UTC. **Correct.**
-`schedule_event_reschedule` (`:801803`)
-`public_book_submit` (`:15051507`)
-`public_manage_reschedule` (`:889891`)
The three ❌ paths do `tz.localize(naive).astimezone(utc)` — i.e. they treat an
already-UTC string as *local* and convert **again**, shifting the appointment by the
user's UTC offset. It is **silent when the resolved tz is UTC** (UTC server, no `tz`
cookie / `user.tz`), which is why it can pass casual testing — but with the
`tz`-cookie set by fusion_portal (e.g. `America/Toronto`, §4.5) a reschedule or **any**
public booking lands 45 h off. **Fix:** in those three paths, treat the slot string as
UTC exactly like `schedule_book_submit` (drop the `localize`/`astimezone`).
2. **Google pull is coupled to the server's OS timezone.** In
`_google_event_to_odoo_vals` (`fusion_calendar_account.py:530`):
`start_dt.astimezone(tz=None).replace(tzinfo=None)``astimezone(None)` converts an
aware datetime to the **system local** zone, not UTC. Odoo stores naive **UTC**, so
pulled Google events are correct **only if the container runs UTC**. The Microsoft path
parses as naive-UTC and is fine. **Fix:** `.astimezone(pytz.utc).replace(tzinfo=None)`.
3. **Public booking does not re-validate the slot** (`public_book_submit`) — see §14.11.
Combined with #1 it means the public path can both mis-time *and* double-book.
### 🟠 Gaps between documented intent and implementation
4. **"Busy on one, blocked on all" is enforced at *portal-booking time*, not by syncing
events between external calendars.** `_cross_calendar_push` **skips any event that
already has a link** (`if existing_links: continue`), and every *pulled* event has a
link — so a Google event is **never** pushed into the user's Outlook (and vice-versa).
What actually delivers "blocked on all" is `_generate_available_slots`, which searches
**all** of the user's `calendar.event` rows (everything pulled from every calendar) when
computing free slots — so booking **through `/my/schedule`** respects every connected
calendar. Booking *directly* in Google will not block Outlook. `_cross_calendar_push`
only mirrors **Odoo-native** events to the **first** active account. The manifest's
"busy on one, blocked on all" oversells the cross-external behaviour — state it as
*portal-booking-time* blocking.
### 🟡 Risks / abuse vectors
5. **Slug generation can block user creation.** `res.users.create` sets
`x_fc_schedule_slug` for **every** new user, guarded by `UNIQUE(x_fc_schedule_slug)`. The
4-hex suffix gives 1/65536 collision odds per name-base; a collision raises the
constraint and **fails the whole user-creation transaction** (no retry). Low probability,
high blast radius — consider a retry/uniqueness loop if user-creation volume grows.
6. **Unthrottled public booking.** `/schedule/<slug>/book` creates a `res.partner`, a
`calendar.event`, and force-sends an email for any visitor with **no captcha / rate
limit**. A scripted abuser can spam partners + events + outbound mail. Consider a
throttle / honeypot if the slug links are widely shared.
7. **Synchronous external HTTP inside `calendar.event.write()/unlink()`.** Because
fusion_schedule is the **sole** `calendar.event` extender (verified — see below), its
overrides fire for **every** event in the system. For a *linked* event, a write that
touches a sync field makes a **blocking** Google/Microsoft API call inside the caller's
transaction; a bulk write/delete over many linked events ⇒ N serial HTTP round-trips,
potentially stalling that request/transaction. Keep this in mind before bulk-editing
calendar events in any module.
### 🔬 Deep-dive #5 additions — sync-dedup cluster + public-endpoint security
Found by an adversarial re-read (all verified against code). Full detail + fixes in Supabase
`fusionapps.issues` (project Fusion Schedule). The **dedup cluster (810) is the most serious
— it corrupts data across users**:
8. **🔴 `_find_existing_event` merges events across users + resurrects archived ones.**
`fusion_calendar_account.py:401-417` dedups by **name+start+stop only**, on `.sudo()`
(record rules bypassed), **unscoped** by user/partner/company. Two staff with a same-titled
same-time event (Standup, Lunch, an org-wide invite) → user B's sync **reuses user A's
`calendar.event`** and links B's account onto it; also **reactivates a deliberately-archived
event**. Runs as root in cron → crosses companies. Fix: scope to
`partner_ids in [self.x_fc_user_id.partner_id]` + `x_fc_source_account_id in [self.id, False]`;
never auto-reactivate an event with no surviving link to this account.
9. **🔴 iCalUID cross-link is unscoped.** `fusion_calendar_account.py:482-489` (Google) /
`715-724` (MS) match `x_fc_universal_id` across **all** accounts/users. A real invite sent to
two staff shares one iCalUID → user B's account links onto user A's event; B never gets their
own row. Fix: scope the lookup to `x_fc_account_id.x_fc_user_id = self.x_fc_user_id.id`.
10. **🔴 No per-row isolation in the sync loop.** `_sync_pull_google/_microsoft` loop
`_process_*_event` with no savepoint and write `sync_token` **after** the loop. One row
exception (e.g. an IntegrityError — `_upsert_event_link` branches on `(account,event_id)` at
`:419-445` but the UNIQUE is `(account,external_id)` at `fusion_calendar_event_link.py:32`)
rolls back the whole page and **never advances `sync_token`** → deterministic errors wedge
the account forever. Fix: `with self.env.cr.savepoint():` per row; branch the upsert on
`(account, external_id)`.
11. **🔴 MS delta page-cap stalls large calendars.** `_sync_pull_microsoft` caps at 2000/5000
and `break`s without the `@odata.deltaLink` (`:601-606`), writing back the old token → a
>2000-event window re-fetches the same 2000 forever and never delivers the rest. The
410/`fullSyncRequired` recursion (`:318-321`, `:588-591`) has **no depth guard**.
12. **🟡 Public booking mutates/attaches an existing partner by email.**
`public_book_submit` (`portal_schedule.py:1516-1525`) does
`Partner.search([('email','=ilike', visitor_email)])` then writes `phone` onto the match and
attaches it as an attendee. An anonymous visitor can pollute an arbitrary contact (incl.
staff), pull internal partners into an event, and mail arbitrary addresses. Fix: on the
public path, never mutate/attach a partner matched only by attacker-supplied email.
13. **🟡 Manage-token leaks via redirect URL + no re-validation + no throttle.** The success
redirect puts the 32-char bearer token in an in-page URL query string
(`portal_schedule.py:1590-1594`) → leaks via history + `Referer` to Google Maps assets.
`public_manage_reschedule` (`:876-903`) also skips slot re-validation; public routes are
unthrottled. (Token entropy itself is fine.) Fix: keep the token in the emailed link only,
add `Referrer-Policy: no-referrer`, re-validate, throttle.
14. **🟡 `sync_current_user` commits mid-loop** (`:1097`) — non-atomic inside an interactive
RPC; reports `{success: False}` after already persisting earlier accounts.
15. **🟡 Dead imports** trip pyflakes: `import secrets` (`calendar_event.py:4`) and
`import hashlib` (`controllers/portal_schedule.py:4`) are unused. (`res_users.py` is fine —
it uses `uuid`.)
> Refinement to #4: `_cross_calendar_push` is **also** gated by `len(user_accounts) > 1`
> (`:1149`), so **single-account users never get their Odoo-native events pushed out at all**,
> and the `start >= now-1d` filter excludes all-day events. So even the portal-side mirroring is
> partial.
### 🧱 Deep-dive #6 — install / render / Odoo-19-API correctness (the AI-codegen layer)
**Clean meta-result:** a grep for every repo-documented Odoo-19 anti-pattern came back empty —
no `type="json"`, `groups_id`, `_sql_constraints`, `numbercall`, `useService('rpc')`,
`category_id`, `fields.Date` in settings, or SCSS `@import`; `models.Constraint`/`models.Index`,
`@api.model_create_multi`, the OWL import path, and route types are all **correct** Odoo 19. So
the AI (Cursor + Claude 4.5 Opus) got the *syntax/idioms* right; the defects are semantic
(logic/integration/tz), plus these render/version items:
16. **🟡 `today` undefined on the public booking page.** `public_booking.xml:79`
(`t-att-min="today"`) but `public_booking_page` (`:1418-1426`) never passes `today`
(the authenticated page correctly passes `now`). At minimum the public date picker loses its
min-date guard (visitor can pick a past date → server returns 0 slots). **Confirm on Odoo 19
whether QWeb omits the attr or 500s** — the public page looks untested. Copy-paste drift.
17. **🟡 Confirmation email renders UTC times + wrong language.** `mail_template_data.xml`
`t-out object.start/stop` with the `datetime` widget renders in the **renderer's tz** (UTC on
`force_send` from a portal request) → email shows UTC, not the client's local time. And
`lang = {{ object.partner_ids[:1].lang }}` picks the **first** partner = the **staff** user,
not the client. (Mail body is otherwise rule-17-safe — no `url_encode`/undefined names;
`res.company.website` + `get_base_url()` resolve.)
18. **🟡 Address-autocomplete drift.** The asset JS stores province as full name
(`portal_schedule_booking.js:546`, `long_name` → "Ontario"); the public inline JS stores the
2-letter code (`public_booking.xml:318`, `short_name` → "ON"). Same field, two formats. The
asset version also omits the Places `fields:[...]` filter → Google all-fields billing tier.
19. **🟠 `_prepare_calendar_event_values` signature unverified.** `portal_schedule.py:717-730`
calls this **private Enterprise** method (signature shifted across 16→19). A mismatched kwarg
raises `TypeError`, swallowed by the `except` at `:766` → **authenticated bookings silently
never get created**. The public path builds vals by hand (a tell). **Needs a booking
smoke-test on Enterprise** — couldn't byte-verify (Docker/Odoo source unreachable).
**Version-fragility notes (work now, but verify on Odoo point-upgrades — not logged as bugs):**
- The backend patch xpaths `//div[@id='header_synchronization_settings']`
(`fusion_calendar_controller.xml:10,15`) against `calendar.AttendeeCalendarController`. It
resolves on the deployed version (else the *entire* `web.assets_backend` bundle would be dead),
but a future Odoo restructure of that template would brick the bundle. Prefer a stabler
selector when next touched.
- The `appointment.invite` seed (`appointment_invite_data.xml:8`) has empty
`appointment_type_ids` **and** no `staff_user_ids`, so `schedule_page`'s `share_url`
(`invite.book_url`) never resolves for anyone — the seed is inert (the `/schedule/<slug>` flow
is the real share). Reconcile or drop it.
### ✅ Audit results that came back clean (good to know)
- **No `x_fc_*` field-name collisions.** None of `x_fc_schedule_slug / _booking_enabled /
_work_start / _work_end / _break_start / _travel_buffer / _home_address / _home_lat`
appears in any other module.
- **`calendar.event` is inherited by `fusion_schedule` alone** (whole repo). Its
`write/unlink` overrides are the only custom hooks on that model — but they run for every
calendar event once installed (see risk #7).
- **No conflicting `res.users.create()` override in the dependency chain.** `fusion_portal`
only overrides `_generate_tutorial_articles` / `portal.wizard.user`; `fusion_tasks` adds
`x_fc_is_field_staff / x_fc_start_address / x_fc_tech_sync_id` (no `create`, no overlap).
So the `@api.model_create_multi create()` slug hook chains cleanly via `super()`.
---
## 17. File index
```
fusion_schedule/
├── __manifest__.py # deps, data load order, assets (v19.0.2.1.0)
├── controllers/portal_schedule.py # ALL routes + slot gen + travel + AI + OAuth (~1600 lines)
├── models/
│ ├── fusion_calendar_account.py # OAuth + sync engine (the core)
│ ├── fusion_calendar_event_link.py # Odoo↔external join (unique per account)
│ ├── calendar_event.py # inherit: source/links/manage-token/travel + write/unlink push
│ ├── res_users.py # inherit: slug, booking flag, work prefs, auto-slug
│ └── res_config_settings.py # OAuth creds + sync interval + schedule defaults
├── data/
│ ├── ir_cron_data.xml # 5-min sync cron
│ ├── mail_template_data.xml # booking confirmation email (NOT noupdate)
│ └── appointment_invite_data.xml # default share invite (noupdate, empty types)
├── security/{security.xml, ir.model.access.csv}
├── views/
│ ├── fusion_calendar_account_views.xml # backend list/form + Technical menu
│ ├── res_config_settings_views.xml # Settings app block
│ ├── portal_schedule_tile.xml # tile into fusion_portal.portal_my_home_authorizer
│ ├── portal_schedule.xml # portal_schedule_page + portal_schedule_book
│ └── public_booking.xml # public_booking_page + public_manage_page
├── static/src/
│ ├── css/portal_schedule.css
│ ├── js/portal_schedule_booking.js # booking form + Places autocomplete + AI suggest
│ ├── js/portal_schedule_accounts.js # dashboard modals/toasts + optimize
│ └── views/fusion_calendar_controller.{js,xml} # backend calendar patch
├── utils/__init__.py # empty placeholder
└── graphify-out/ # Cursor code-graph artifact — NOT loaded by Odoo
```

View File

@@ -1,386 +0,0 @@
# fusion_schedule — CODE MAP (where-is-what index)
> Precise symbol-level index for the whole module. Companion to `CLAUDE.md` (which is the
> narrative/guidance doc). **This file = "where is X".** Line numbers are exact at the time of
> writing (2026-06-03); re-grep `def `/`fields.`/`@http.route`/`<template id=` if they drift.
> Audit findings live in `CLAUDE.md §16` and in Supabase `fusionapps.issues`
> (project **Fusion Schedule** = `576de219-57e6-4596-8c8c-0c093e4cb54a`).
## 0. File tree (sizes approximate, by cat -n)
```
fusion_schedule/
├── __manifest__.py 61 deps, data load order, assets (v19.0.2.1.0)
├── __init__.py 3 → controllers, models
├── controllers/
│ ├── __init__.py 2 → portal_schedule
│ └── portal_schedule.py ~1607 PortalSchedule(CustomerPortal): 23 routes + helpers
├── models/
│ ├── __init__.py 6 load order (see below)
│ ├── fusion_calendar_account.py ~1191 sync engine + OAuth + cron (THE core)
│ ├── fusion_calendar_event_link.py 30 Odoo↔external join table
│ ├── calendar_event.py 89 inherit: sync fields + write/unlink push
│ ├── res_users.py 69 inherit: slug + work prefs + auto-slug create()
│ └── res_config_settings.py 74 inherit: OAuth creds + sync interval + defaults
├── data/
│ ├── ir_cron_data.xml 13 5-min sync cron
│ ├── mail_template_data.xml 155 booking confirmation email
│ └── appointment_invite_data.xml 10 default share invite (noupdate)
├── security/
│ ├── security.xml 17 2 record rules
│ └── ir.model.access.csv 5 4 ACL rows
├── views/
│ ├── fusion_calendar_account_views.xml 64 backend list/form/action/menu
│ ├── res_config_settings_views.xml 148 Settings app block
│ ├── portal_schedule_tile.xml 25 tile into fusion_portal home
│ ├── portal_schedule.xml 833 portal_schedule_page + portal_schedule_book
│ └── public_booking.xml 586 public_booking_page + public_manage_page (inline JS)
├── static/src/
│ ├── css/portal_schedule.css 48 responsive helpers only
│ ├── js/portal_schedule_booking.js ~575 booking form (authenticated)
│ ├── js/portal_schedule_accounts.js ~489 dashboard modals/toasts/optimize
│ └── views/fusion_calendar_controller.{js,xml} 68/44 backend AttendeeCalendarController patch
├── utils/__init__.py 1 empty placeholder
└── graphify-out/ — Cursor artifact, NOT loaded by Odoo
```
Model load order (`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link
→ calendar_event → res_users → res_config_settings`.
---
## 1. Models
### 1.1 `fusion.calendar.account` — `models/fusion_calendar_account.py`
`_order = 'x_fc_provider, x_fc_email'`. Module constants (top of file): `TIMEOUT=20` (14),
`MAX_THROTTLE_RETRIES=3` (15), `DEFAULT_RETRY_SECONDS=10` (16); Google endpoints 1923,
Microsoft endpoints 2634.
**Fields**
| line | field | type / notes |
|---|---|---|
| 42 | `x_fc_user_id` | m2o res.users · required · cascade · default=current user · index |
| 46 | `x_fc_provider` | sel google/microsoft · required |
| 50 | `x_fc_email` | char |
| 51 | `x_fc_name` | char · compute `_compute_name` · store |
| 52 | `x_fc_active` | bool · default True |
| 55 | `x_fc_rtoken` | char · **groups=base.group_system** |
| 56 | `x_fc_token` | char · **group_system** |
| 57 | `x_fc_token_validity` | datetime · **group_system** |
| 60 | `x_fc_sync_token` | char · **group_system** (delta/sync token) |
| 61 | `x_fc_calendar_id` | char · default `'primary'` |
| 62 | `x_fc_last_sync` | datetime |
| 63 | `x_fc_sync_status` | sel active/error/paused · default active |
| 68 | `x_fc_error_message` | text |
| 71 | `x_fc_link_ids` | o2m → fusion.calendar.event.link |
**Methods**
| line | method | purpose |
|---|---|---|
| 76 | `_compute_name` | "Provider — email" label |
| 85/92/99/106 | `_get_{google,microsoft}_client_{id,secret}` | creds: `fusion_schedule_*` ICP → native `*_calendar_*` ICP fallback |
| 117 | `_get_valid_token` | return token, refresh if <1 min to expiry |
| 130 | `_refresh_token` | dispatch to provider refresh; on 400/401 mark error + clear |
| 149 / 170 | `_refresh_google_token` / `_refresh_microsoft_token` | OAuth refresh (MS may rotate rtoken) |
| 200 / 213 | `_exchange_{google,microsoft}_code` | code→tokens (called from callback) |
| 232 / 243 | `_fetch_{google,microsoft}_email` | `@api.model` · whoami email |
| 258 | `_sync_pull` | entry: dispatch pull per provider, catch+record errors |
| 293 | `_sync_pull_google` | events.list paging; 410→drop token+full resync; window 14/+30d |
| 362 | `_google_request_with_retry` | GET w/ 429/503 + connection retry |
| 389 | `_silent_ctx` | context flags that suppress mail + re-push (load-bearing) |
| 401 | `_find_existing_event` | dedup by name+start+stop (incl. archived) |
| 419 | `_upsert_event_link` | create/update the join row |
| 447 | `_process_google_event` | upsert one Google event (3-tier dedup) |
| 503 | `_google_event_to_odoo_vals` | ⚠ uses `astimezone(None)` — server-tz bug (CLAUDE §16.2) |
| 550 | `_sync_pull_microsoft` | Graph `calendarView/delta`; page cap 2000/5000 |
| 642 | `_microsoft_request_with_retry` | GET w/ retry |
| 671 | `_process_microsoft_event` | upsert one MS event; `@removed=changed``'skipped'` (don't archive) |
| 738 | `_microsoft_event_to_odoo_vals` | MS dict→Odoo vals |
| 798 | `_fetch_microsoft_event_subject` | fallback fetch when delta omits subject |
| 821 | `_sync_push_event` | push one Odoo event (insert/patch per provider) |
| 870/884/896 | `_google_{insert,patch,delete}_event` | Google write API |
| 908/924/938 | `_microsoft_{insert,patch,delete}_event` | Graph write API |
| 953 / 977 | `_odoo_event_to_{google,microsoft}` | Odoo→external format |
| 1022 | `_cross_calendar_push` | push **unlinked Odoo-native** events to **first** account (CLAUDE §16.4) |
| 1066 | `get_user_accounts_status` | `@api.model` **[backend RPC]** — account chips |
| 1081 | `sync_current_user` | `@api.model` **[backend RPC]** — "Sync now" (commits per account) |
| 1116 | `_cron_sync_all_accounts` | `@api.model` **[cron]** — sync all, then cross-push per multi-acct user |
| 1161 | `action_disconnect` | delete pushed external events, unlink, pause |
### 1.2 `fusion.calendar.event.link` — `models/fusion_calendar_event_link.py`
`_order = 'x_fc_last_synced desc'`. Fields: `x_fc_event_id` (11, m2o calendar.event, cascade),
`x_fc_account_id` (15, m2o account, cascade), `x_fc_external_id` (19, req, index),
`x_fc_universal_id` (22, iCalUID, index), `x_fc_last_synced` (25), `x_fc_sync_direction`
(26, pull/push/both). **Constraint** `_unique_account_external` = `UNIQUE(x_fc_account_id,
x_fc_external_id)` (32).
### 1.3 `calendar.event` (inherit) — `models/calendar_event.py`
**Sole extender of `calendar.event` in the whole repo.** Fields: `x_fc_source_account_id`
(14), `x_fc_is_external` (18, compute+store), `x_fc_link_ids` (21), `x_fc_manage_token`
(24, index, copy=False), `x_fc_client_email` (28), `x_fc_client_phone` (29),
`x_fc_address_lat` (30), `x_fc_address_lng` (31), `x_fc_travel_minutes_before` (32),
`x_fc_is_travel_block` (36). Methods: `_compute_is_external` (42), `_skip_fc_sync` (46),
`unlink` (51, deletes from external), `write` (76, pushes to external) — both gated by
`_skip_fc_sync()` + presence of links. ⚠ external HTTP is synchronous (CLAUDE §16.7).
### 1.4 `res.users` (inherit) — `models/res_users.py`
Fields: `x_fc_calendar_account_ids` (12), `x_fc_schedule_slug` (16), `x_fc_booking_enabled`
(21), `x_fc_work_start` (26), `x_fc_work_end` (30), `x_fc_break_start` (34),
`x_fc_break_duration` (38), `x_fc_travel_buffer` (42), `x_fc_home_address` (46),
`x_fc_home_lat` (50), `x_fc_home_lng` (51). **Constraint** `_unique_schedule_slug` =
`UNIQUE(x_fc_schedule_slug)` (53). Methods: `create` (59, `@api.model_create_multi`,
auto-slug — ⚠ collision risk CLAUDE §16.5), `_generate_schedule_slug` (66).
### 1.5 `res.config.settings` (inherit) — `models/res_config_settings.py`
Fields (12): `x_fc_google_client_id` (10), `_secret` (14), `_has_fallback` (18);
`x_fc_microsoft_client_id` (24), `_secret` (28), `_has_fallback` (32);
`x_fc_sync_interval_minutes` (38, **not wired to cron**); `x_fc_default_work_start` (45),
`_work_end` (50), `_break_start` (55), `_break_duration` (60), `_travel_buffer` (65).
Methods: `_compute_google_has_fallback` (72), `_compute_microsoft_has_fallback` (79).
---
## 2. Controller — `controllers/portal_schedule.py` (`PortalSchedule(CustomerPortal)`)
**Helper methods**
| line | method | purpose |
|---|---|---|
| 30 | `_get_schedule_values` | portal gradient (fusion_claims params) + maps key |
| 43 | `_get_user_timezone` | → `_resolve_timezone(env.user)` |
| 46 | `_resolve_timezone` | user.tz → `tz` cookie → company cal → UTC |
| 69 | `_get_appointment_types` | types where current user is staff |
| 75 | `_get_user_prefs` | per-user prefs w/ company-default fallback |
| 101 | `_get_maps_api_key` | `fusion.api.service``fusion_claims.google_maps_api_key` |
| 114 | `_call_ai` | `fusion.api.service.call_openai` → direct OpenAI HTTP |
| 147 | `_get_travel_time` | Google Distance Matrix (min) |
| 178 | `_geocode_address` | Google Geocoding (lat,lng) |
| 200 | `_create_travel_blocks` | insert "Travel to …" placeholder events |
| 425 | `_format_hour` | staticmethod · 13.5 → "1:30 PM" |
| 435 | `_generate_available_slots` | **shared slot core**; emits UTC `datetime` (line 520) |
| 825 | `_get_event_by_token` | manage-token lookup (len==32) |
| 932 | `_build_schedule_context` | AI prompt context builder |
| 1336 | `_find_recently_connected_account` | OAuth callback resilience |
**Routes** (23 total)
| line | verb | path | auth | handler |
|---|---|---|---|---|
| 288 | http | `/my/schedule` | user | `schedule_page` |
| 363 | jsonrpc | `/my/schedule/preferences` | user | `schedule_save_preferences` |
| 397 | http | `/my/schedule/book` | user | `schedule_book` |
| 530 | jsonrpc | `/my/schedule/available-slots` | user | `schedule_available_slots` |
| 560 | jsonrpc | `/my/schedule/week-events` | user | `schedule_week_events` |
| 630 | http POST | `/my/schedule/book/submit` | user | `schedule_book_submit` ✅ tz-correct |
| 777 | jsonrpc | `/my/schedule/event/cancel` | user | `schedule_event_cancel` |
| 792 | jsonrpc | `/my/schedule/event/reschedule` | user | `schedule_event_reschedule` ⚠ tz-bug |
| 834 | http | `/schedule/manage/<token>` | public | `public_manage_page` |
| 860 | http POST | `/schedule/manage/<token>/cancel` | public | `public_manage_cancel` |
| 876 | http POST | `/schedule/manage/<token>/reschedule` | public | `public_manage_reschedule` ⚠ tz-bug |
| 907 | jsonrpc | `/schedule/manage/<token>/available-slots` | public (csrf=False) | `public_manage_slots` |
| 982 | jsonrpc | `/my/schedule/ai/suggest` | user | `schedule_ai_suggest` |
| 1093 | jsonrpc | `/my/schedule/ai/optimize` | user | `schedule_ai_optimize` |
| 1155 | http | `/my/schedule/connect/google` | user | `connect_google` |
| 1192 | http | `/my/schedule/connect/microsoft` | user | `connect_microsoft` |
| 1230 | http | `/my/schedule/oauth/callback` | user | `oauth_callback` |
| 1356 | jsonrpc | `/my/schedule/disconnect` | user | `schedule_disconnect` |
| 1370 | jsonrpc | `/my/schedule/sync-now` | user | `schedule_sync_now` |
| 1398 | http | `/schedule/<slug>` | public | `public_booking_page` |
| 1431 | jsonrpc | `/schedule/<slug>/available-slots` | public (csrf=False) | `public_available_slots` |
| 1465 | http POST | `/schedule/<slug>/book` | public (csrf) | `public_book_submit` ⚠ tz-bug + no re-validate |
| 1602 | jsonrpc | `/my/schedule/toggle-booking` | user | `schedule_toggle_booking` |
---
## 3. Frontend JS
### 3.1 backend patch — `static/src/views/fusion_calendar_controller.js`
`patch(AttendeeCalendarController.prototype)`: `setup`, getters `fusionAccounts` /
`fusionSyncing`, `_loadFusionAccounts` (→ `get_user_accounts_status`), `onFusionSyncNow`
(→ `sync_current_user`). Template `.xml` hides `#header_synchronization_settings`, injects
account chips + sync button + cog→`/my/schedule`.
### 3.2 `static/src/js/portal_schedule_booking.js` (authenticated book page)
`setTzCookie` (4), `getAppointmentTypeId` (35), `truncate` (41), `formatDateStr` (46),
`addDays` (53), `getMonday` (59), `selectDay` (67), `fetchWeekEvents` (77) →
`/my/schedule/week-events`, `navigateWeek` (120), `renderWeekCalendar` (140), `fetchSlots`
(260) → `/my/schedule/available-slots`, `renderGroup` (319, nested), `fetchAiSuggestions`
(364) → `/my/schedule/ai/suggest`, `setupAddressAutocomplete` (516, Google Places).
### 3.3 `static/src/js/portal_schedule_accounts.js` (dashboard)
Utils: `localDateStr` (4), `setTzCookie` (12), `jsonRpc` (21), `fusionConfirm` (30),
`fusionToast` (87, builds `#fusionToastLive` — template `#fusionToast` is dead),
`closeRescheduleModal` (304), `closeOptimizeModal` (474). Event bindings: disconnect (112)
`/disconnect`, sync (141) → `/sync-now`, share (160), save-prefs (186) → `/preferences`,
cancel (231) → `/event/cancel`, reschedule open (274) + date-change (321) +
confirm (375) → `/event/reschedule`, optimize (413) → `/ai/optimize`.
> Public pages (`public_booking_page`, `public_manage_page`) carry their **own inline
> `<script>`** in `public_booking.xml` (a 2nd copy of slot-render + Places autocomplete +
> reschedule) — they do **not** use the files above. See CLAUDE §9.1.
### 3.4 DOM-id contract (templates ↔ JS)
Book page: `bookingDate`, `appointmentTypeSelect`, `slotsContainer/slotsGrid/slotsLoading/
noSlots`, `slotDatetime`, `slotDuration`, `weekCalendar{Container,Loading,Grid,Header,Body,
Empty,Nav}`, `btnPrevWeek/btnNextWeek/weekRangeLabel`, `aiSuggest{Section,Loading,Grid}`,
`btnAiSuggest`, `clientStreet/City/Province/Postal/Lat/Lng`, `btnSubmitBooking`.
Dashboard: `fusionConfirmModal`(+Title/Message/Ok), `rescheduleModal`(+Date/SlotsContainer/
SlotsGrid/EventId/SlotDatetime/EventDuration/EventName/AppTypeId/btnConfirmReschedule),
`optimizeModal`(+Loading/Result/CurrentTravel/NewTravel/Savings/ScheduleList/Error),
`schedulePrefsForm`/`btnSavePrefs`/`prefsSavedMsg`, `btnOptimizeSchedule`, `.js-*` classes.
Public: `publicBookingDate`, `publicSlots*`, `publicSlotDatetime/Duration`, `publicBtnSubmit`,
`publicAppointmentType`, `publicClient*`, `publicReschedule*`.
---
## 4. Templates / data / security / settings
**Templates**
| id | file:line | base layout |
|---|---|---|
| `portal_schedule_page` | portal_schedule.xml:6 | `portal.portal_layout` |
| `portal_schedule_book` | portal_schedule.xml:605 | `portal.portal_layout` |
| `public_booking_page` | public_booking.xml:6 | `website.layout` (+inline JS) |
| `public_manage_page` | public_booking.xml:345 | `website.layout` (+inline JS) |
| `portal_my_home_schedule` | portal_schedule_tile.xml:5 | inherit `fusion_portal.portal_my_home_authorizer` |
| `FusionCalendarController` | fusion_calendar_controller.xml | t-inherit `calendar.AttendeeCalendarController` |
| `res_config_settings_view_form_fusion_schedule` | res_config_settings_views.xml:4 | inherit `base.res_config_settings_view_form` |
**Backend views**`fusion_calendar_account_views.xml`: list (5), form (24),
`action_fusion_calendar_account` (56), `menu_fusion_calendar_account` (63, under
`base.menu_custom`).
**Data**`ir_cron_fusion_calendar_sync` (ir_cron_data.xml:4, 5 min, `_cron_sync_all_accounts`);
`fusion_schedule_booking_confirmation` (mail_template_data.xml:4, model `calendar.event`,
NOT noupdate); `default_appointment_invite` (appointment_invite_data.xml:8, noupdate,
short_code `book-appointment`, empty types).
**Security** — rules `fusion_calendar_account_user_rule` (security.xml:5),
`fusion_calendar_event_link_user_rule` (security.xml:13); ACL: 4 rows in
`ir.model.access.csv` (account: CRUD user / none public; link: CRU user / full system).
---
## 5. Config parameters (`ir.config_parameter`)
**Owned**`fusion_schedule_google_client_id`, `_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `_microsoft_client_secret`,
`fusion_schedule_sync_interval`; `fusion_schedule.default_work_start` / `_work_end` /
`_break_start` / `_break_duration` / `_travel_buffer`.
**Fallback (not owned)**`google_calendar_client_id` / `_secret`,
`microsoft_calendar_client_id` / `_secret`, `web.base.url`; and the fusion_claims namespace
`fusion_claims.portal_gradient_start/_mid/_end`, `fusion_claims.google_maps_api_key`,
`fusion_claims.ai_api_key`.
---
## 6. External HTTP it talks to
- **Google** OAuth (`accounts.google.com/o/oauth2/auth`, `oauth2.googleapis.com/token`),
Calendar v3 (`googleapis.com/calendar/v3`), userinfo, Distance Matrix + Geocoding
(`maps.googleapis.com`). Scopes: `calendar` + `userinfo.email`.
- **Microsoft** OAuth (`login.microsoftonline.com/common/oauth2/v2.0/*`), Graph
(`graph.microsoft.com/v1.0``me/calendarView/delta`, `me/events`, `me`). Scopes:
`offline_access openid Calendars.ReadWrite User.Read`.
- **OpenAI** `api.openai.com/v1/chat/completions` (`gpt-4o-mini`) — fallback only.
---
## 7. Cross-module touchpoints (full detail in CLAUDE §4)
| direction | what | where |
|---|---|---|
| depends ↓ | `fusion_portal` (→ fusion_claims stack) | __manifest__.py:35 |
| inherit ↓ | `fusion_portal.portal_my_home_authorizer` | portal_schedule_tile.xml:6 |
| soft-call ↓ | `fusion.api.service` (fusion_api) | portal_schedule.py:104,117 |
| ICP read ↓ | `fusion_claims.{portal_gradient_*,google_maps_api_key,ai_api_key}` | portal_schedule.py:33-35,111,126 |
| cookie ← | `tz` set by `fusion_portal/.../timezone_detect.js` | portal_schedule.py:_resolve_timezone |
| shared table | `calendar.event` (also written by fusion_claims schedule wizard / appointment) | models/calendar_event.py |
| reverse | **none** (only fusion_repairs lists it as *deferred*) | — |
---
## 8. Audit cross-reference
**19 findings** logged → Supabase `fusionapps.issues`, project **Fusion Schedule**
(`576de219-57e6-4596-8c8c-0c093e4cb54a`), all `status='open'`. Detail + fixes in
`CLAUDE.md §16` (deep dives #1#6). Provenance: AI-generated (Cursor + Claude 4.5 Opus) —
Odoo-19 syntax clean, bugs are semantic. Headlines: (a) timezone double-conversion on `schedule_event_reschedule` /
`public_book_submit` / `public_manage_reschedule` (slot string is UTC but they re-localize);
(b) the **sync-dedup cluster**`_find_existing_event` (`:401`) and the iCalUID lookup
(`:482`/`:715`) are unscoped by user, so same-titled / shared-invite events **merge across
different users** and resurrect archived ones; (c) public booking mutates an existing
`res.partner` by attacker-supplied email.
---
## 9. Consumed contracts — the OTHER side of each cross-module link (integration boundary)
### 9.1 `fusion.api.service` broker (`fusion_api`, **not a manifest dep**)
`request.env['fusion.api.service']`**`KeyError` if `fusion_api` absent** (caught by
fusion_schedule's bare `except` → fallback). 7 models: `fusion.api.service` (AbstractModel,
broker), `fusion.api.{provider,key,consumer,access,usage,user.limit}` + `usage.daily`.
Public methods fusion_schedule uses: `get_api_key(provider_type, consumer, feature)`
`api_service.py:394`; `call_openai(consumer, feature, messages, model)``:278`. **Raises
`UserError` on 14 conditions** (no active provider `:62`; consumer disabled `:129`; access
disabled `:141`; monthly/daily budget `:157/167`; rpm/rpd `:185/194`; user blocked/budget/rpd
`:218/224/234`; no key `:81`; package missing `:280/335`; downstream API error `:319/381`) —
**any** of these (or KeyError) triggers fusion_schedule's ICP fallback. `provider_type` enum:
`openai, anthropic, google_maps, google_oauth, microsoft_oauth, twilio, custom`. Consumer
auto-registers when `fusion_api.auto_detect_consumers` (default True).
> ⚠ **`get_api_key` returns `key.api_key` (a `group_admin`-gated field) on a *non-sudo*
> recordset (`api_service.py:407`).** For a portal/public request (non-admin/public user) this
> likely raises `AccessError` → fusion_schedule's fallback fires **every time** → in practice
> the maps key for portal/public callers comes from `fusion_claims.google_maps_api_key`, not the
> broker. The broker path may effectively never succeed for raw-key access from the portal.
### 9.2 `portal_gradient` / `fc_gradient` / the tile target (`fusion_portal`)
- `portal_gradient` computed in `fusion_portal/controllers/portal_main.py:81-87` from
`fusion_claims.portal_gradient_{start,mid,end}` (defaults `#5ba848/#3a8fb7/#2e7aad`) — **only
set for portal personas** (`is_authorizer/is_sales_rep_portal/is_client_portal/is_technician_portal`).
fusion_schedule computes its **own identical copy** in `portal_schedule.py:33-36`, so its pages
don't need the controller — only the **tile** does (via `fc_gradient`, set at
`portal_templates.xml:10` = `portal_gradient or <default>`).
- **Tile xpath fragility:** the tiles grid is `<div class="row g-3 mb-4">` (`portal_templates.xml:52-295`);
the anchor is the `/my/funding-claims` card (`:277-294`). fusion_schedule's tile xpath
(`portal_schedule_tile.xml:8`) needs **both** the `/my/funding-claims` `<a>` and the exact
`row g-3 mb-4` class triple — change either and the tile **ParseErrors at install** of
fusion_schedule.
- **`tz` cookie** set by `fusion_portal/static/src/js/timezone_detect.js:25`: name `tz`, value
raw IANA (`America/Toronto`), `path=/ max-age=31536000 SameSite=Lax`. Read at
`portal_schedule.py:_resolve_timezone` (2nd priority after `user.tz`).
### 9.3 The borrowed `fusion_claims.*` params — ownership (defaults all match)
| ICP key | owning field | file:line | default |
|---|---|---|---|
| `fusion_claims.portal_gradient_start/_mid/_end` | `fc_portal_gradient_*` | `fusion_claims/.../res_config_settings.py:461-474` | `#5ba848/#3a8fb7/#2e7aad` |
| `fusion_claims.ai_api_key` | `fc_ai_api_key` | `fusion_claims/.../res_config_settings.py:355` | empty |
| `fusion_claims.google_maps_api_key` | `fc_google_maps_api_key` | **`fusion_tasks`/.../res_config_settings.py:12-16** | empty |
> ⚠ The maps key is **owned by `fusion_tasks`, not `fusion_claims`** (the `fusion_claims.*`
> prefix is kept for data continuity). Grepping `fusion_claims/` for it finds nothing. Both
> owners are transitive deps via fusion_portal, so the params are always present.
---
## 10. Sibling scheduling surfaces & how they interact with this module
**Baseline:** fusion_schedule is the **only** `calendar.event` extender; its `write/unlink`
push to external is gated by `_skip_fc_sync()` (context `no_calendar_sync`/`dont_notify`) +
presence of links, and `_cross_calendar_push` (cron) mirrors **unlinked Odoo-native** events
(1d…+90d, on the user's partner) to the **first** account **only if the user has >1 account**.
| Writer | what it creates | interaction with fusion_schedule |
|---|---|---|
| `fusion_claims` `schedule_assessment_wizard.py:186` | 1 `calendar.event`/manual schedule (assessor partner, optional email alarm), **plain create** | eligible for cron mirror; **later edits fire the synchronous `write()` push** |
| `fusion_portal` `portal_assessment.py:1194` | 1 `calendar.event`/public booking (sales-rep partner; sets `accessibility.assessment.calendar_event_id`), **plain sudo create** | same as above |
| `fusion_tasks` `technician_task.py:1572` (`_sync_calendar_event`) | **HIGH volume** — 1 event/task, re-synced on every schedule-field write/create; **writes with `silent_ctx` (`dont_notify=True`)** | synchronous push **suppressed**; external mirror deferred to the 5-min cron. **Protection hinges on `dont_notify` staying in `silent_ctx`** — drop it and every task edit becomes an inline Google/Outlook round-trip |
- **Reverse coupling:** `fusion_tasks` slot scheduler reads `calendar.event` for busy intervals
(`technician_task.py:495-540`) and **excludes its own task-linked events**, so
externally-synced calendar entries (pulled by fusion_schedule) correctly block technician
availability.
- **Repo sweep:** only these **4** modules touch `calendar.event`/appointments; **only
fusion_schedule uses Enterprise `appointment.*`** (the others create raw `calendar.event`).
`fusion_repairs` maintenance booking is still *planned*. Stale vendored copies of the task
engine exist under `Entech Plating/` and `fusion_plating/`**not** the canonical install
path; flag for cleanup.
- **Maps key consumers:** `fusion_tasks` travel-time (`_calculate_travel_time`) and
`fusion_claims` `google_address_autocomplete.js` both read `fusion_claims.google_maps_api_key`
(owned by fusion_tasks) — same key fusion_schedule falls back to.

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
import hashlib
import logging import logging
import secrets import secrets
@@ -795,12 +796,12 @@ class PortalSchedule(CustomerPortal):
if not event.exists() or partner not in event.partner_ids: if not event.exists() or partner not in event.partner_ids:
return {'success': False, 'error': 'Event not found or access denied.'} return {'success': False, 'error': 'Event not found or access denied.'}
# The slot datetime sent by the client is already UTC (the slot tz = self._get_user_timezone()
# generator emits UTC); parse it directly — do NOT re-localize, which
# would double-shift the appointment by the user's UTC offset.
try: try:
start_utc = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S') start_naive = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
except (ValueError, Exception): start_local = tz.localize(start_naive)
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
return {'success': False, 'error': 'Invalid date/time format.'} return {'success': False, 'error': 'Invalid date/time format.'}
duration = float(new_duration) if new_duration else event.duration duration = float(new_duration) if new_duration else event.duration
@@ -882,10 +883,12 @@ class PortalSchedule(CustomerPortal):
if not slot_datetime: if not slot_datetime:
return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token) return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token)
# The slot datetime is already UTC (the slot generator emits UTC); parse tz = self._resolve_timezone(event.user_id)
# directly — do NOT re-localize (that double-shifts by the tz offset).
try: try:
start_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_local = tz.localize(start_naive)
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception): except (ValueError, Exception):
return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token) return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token)
@@ -1496,10 +1499,12 @@ class PortalSchedule(CustomerPortal):
'/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug '/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug
) )
# The slot datetime is already UTC (the slot generator emits UTC); parse tz = self._resolve_timezone(user)
# directly — do NOT re-localize (that double-shifts by the tz offset).
try: try:
start_dt_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e: except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e) _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug) return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug)
@@ -1507,22 +1512,17 @@ class PortalSchedule(CustomerPortal):
duration = float(slot_duration) duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration) stop_dt_utc = start_dt_utc + timedelta(hours=duration)
# Find or create a contact for the visitor. SECURITY: this is an # Find or create partner for the visitor
# unauthenticated endpoint and visitor_email is attacker-controlled, so
# never reuse/attach a partner that backs a login user (staff/internal),
# and never write onto an existing contact. Reuse only a plain non-user
# contact (avoids duplicates for genuine repeat visitors).
Partner = request.env['res.partner'].sudo() Partner = request.env['res.partner'].sudo()
partner = Partner.search([ partner = Partner.search([('email', '=ilike', visitor_email)], limit=1)
('email', '=ilike', visitor_email),
('user_ids', '=', False),
], limit=1)
if not partner: if not partner:
partner = Partner.create({ partner = Partner.create({
'name': visitor_name, 'name': visitor_name,
'email': visitor_email, 'email': visitor_email,
'phone': visitor_phone or False, 'phone': visitor_phone,
}) })
elif visitor_phone and not partner.phone:
partner.phone = visitor_phone
address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p] address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p]
location = ', '.join(address_parts) location = ', '.join(address_parts)

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import secrets
from odoo import api, fields, models from odoo import api, fields, models

View File

@@ -4,7 +4,7 @@ import json
import logging import logging
import time import time
import requests import requests
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -338,17 +338,7 @@ class FusionCalendarAccount(models.Model):
updated = 0 updated = 0
deleted = 0 deleted = 0
for event_data in all_events: for event_data in all_events:
# Per-row savepoint: one bad event must not abort the whole page
# (which would leave sync_token unadvanced and re-fail every cron).
try:
with self.env.cr.savepoint():
result = self._process_google_event(event_data) result = self._process_google_event(event_data)
except Exception as e:
_logger.warning(
"Skipping Google event %s on account %s: %s",
event_data.get('id'), self.id, e,
)
continue
if result == 'created': if result == 'created':
created += 1 created += 1
elif result == 'updated': elif result == 'updated':
@@ -419,15 +409,7 @@ class FusionCalendarAccount(models.Model):
stop_val = vals.get('stop') or vals.get('stop_date') stop_val = vals.get('stop') or vals.get('stop_date')
if not (start_val and stop_val and vals.get('name')): if not (start_val and stop_val and vals.get('name')):
return None return None
# Scope to THIS account's owner so a same-titled, same-time event that domain = [('name', '=', vals['name']), ('active', 'in', [True, False])]
# belongs to a DIFFERENT user is never merged in. Reuse only this
# account's own pulled events, or the user's native (sourceless) events.
domain = [
('name', '=', vals['name']),
('active', 'in', [True, False]),
('partner_ids', 'in', [self.x_fc_user_id.partner_id.id]),
('x_fc_source_account_id', 'in', [self.id, False]),
]
if vals.get('allday'): if vals.get('allday'):
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)] domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
else: else:
@@ -435,20 +417,20 @@ class FusionCalendarAccount(models.Model):
return CalendarEvent.search(domain, limit=1) return CalendarEvent.search(domain, limit=1)
def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid): def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid):
"""Create or update the link for this (account, external event). """Create or update a link between an Odoo event and an external event.
Branches on the table's real UNIQUE key (account, external_id) so it can If this account already has a link to the same Odoo event, update the
never raise an IntegrityError; if the external event is already linked, external_id rather than creating a duplicate link row. Returns the
re-point it at the given Odoo event. Returns the link record. link record.
""" """
existing = EventLink.search([ existing = EventLink.search([
('x_fc_account_id', '=', self.id), ('x_fc_account_id', '=', self.id),
('x_fc_external_id', '=', external_id), ('x_fc_event_id', '=', odoo_event_id),
], limit=1) ], limit=1)
now = fields.Datetime.now() now = fields.Datetime.now()
if existing: if existing:
existing.write({ existing.write({
'x_fc_event_id': odoo_event_id, 'x_fc_external_id': external_id,
'x_fc_universal_id': ical_uid or existing.x_fc_universal_id, 'x_fc_universal_id': ical_uid or existing.x_fc_universal_id,
'x_fc_last_synced': now, 'x_fc_last_synced': now,
}) })
@@ -499,7 +481,7 @@ class FusionCalendarAccount(models.Model):
existing_link = EventLink.search([ existing_link = EventLink.search([
('x_fc_universal_id', '=', ical_uid), ('x_fc_universal_id', '=', ical_uid),
('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id), ('x_fc_universal_id', '!=', False),
], limit=1) if ical_uid else None ], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id: if existing_link and existing_link.x_fc_event_id:
@@ -545,8 +527,8 @@ class FusionCalendarAccount(models.Model):
start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00')) start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00')) end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
# Convert to naive UTC for Odoo # Convert to naive UTC for Odoo
start_utc = start_dt.astimezone(timezone.utc).replace(tzinfo=None) if start_dt.tzinfo else start_dt start_utc = start_dt.astimezone(tz=None).replace(tzinfo=None) if start_dt.tzinfo else start_dt
end_utc = end_dt.astimezone(timezone.utc).replace(tzinfo=None) if end_dt.tzinfo else end_dt end_utc = end_dt.astimezone(tz=None).replace(tzinfo=None) if end_dt.tzinfo else end_dt
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
vals = { vals = {
@@ -585,12 +567,10 @@ class FusionCalendarAccount(models.Model):
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt, MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt,
) )
all_events = []
next_sync_token = self.x_fc_sync_token next_sync_token = self.x_fc_sync_token
page_num = 0 page_num = 0
created = 0 max_events = 5000 if self.x_fc_sync_token else 2000
updated = 0
deleted = 0
processed = 0
while url: while url:
page_num += 1 page_num += 1
@@ -614,28 +594,16 @@ class FusionCalendarAccount(models.Model):
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
# Process each page as it arrives — no unbounded accumulation and no
# event cap that would silently drop everything past the limit. Each
# event gets its own savepoint so one bad row can't abort the page.
page_events = data.get('value', []) page_events = data.get('value', [])
for event_data in page_events: all_events.extend(page_events)
try: _logger.warning("MS sync account %s page %d: %d events (total %d)", self.id, page_num, len(page_events), len(all_events))
with self.env.cr.savepoint():
result = self._process_microsoft_event(event_data) if len(all_events) >= max_events:
except Exception as e:
_logger.warning( _logger.warning(
"Skipping MS event %s on account %s: %s", "MS sync account %s: hit event limit (%d/%d), stopping fetch",
event_data.get('id'), self.id, e, self.id, len(all_events), max_events,
) )
continue break
if result == 'created':
created += 1
elif result == 'updated':
updated += 1
elif result == 'deleted':
deleted += 1
processed += 1
_logger.warning("MS sync account %s page %d: %d events (processed %d total)", self.id, page_num, len(page_events), processed)
url = data.get('@odata.nextLink') url = data.get('@odata.nextLink')
if not url: if not url:
@@ -643,6 +611,21 @@ class FusionCalendarAccount(models.Model):
if '$deltatoken=' in delta_link: if '$deltatoken=' in delta_link:
next_sync_token = delta_link.split('$deltatoken=')[-1] next_sync_token = delta_link.split('$deltatoken=')[-1]
_logger.warning("MS sync account %s: processing %d events...", self.id, len(all_events))
created = 0
updated = 0
deleted = 0
for i, event_data in enumerate(all_events):
result = self._process_microsoft_event(event_data)
if result == 'created':
created += 1
elif result == 'updated':
updated += 1
elif result == 'deleted':
deleted += 1
if (i + 1) % 25 == 0:
_logger.warning("MS sync account %s: processed %d/%d events", self.id, i + 1, len(all_events))
self.sudo().write({ self.sudo().write({
'x_fc_sync_token': next_sync_token, 'x_fc_sync_token': next_sync_token,
'x_fc_last_sync': fields.Datetime.now(), 'x_fc_last_sync': fields.Datetime.now(),
@@ -731,7 +714,7 @@ class FusionCalendarAccount(models.Model):
existing_link = EventLink.search([ existing_link = EventLink.search([
('x_fc_universal_id', '=', ical_uid), ('x_fc_universal_id', '=', ical_uid),
('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id), ('x_fc_universal_id', '!=', False),
], limit=1) if ical_uid else None ], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id: if existing_link and existing_link.x_fc_event_id:

View File

@@ -1,6 +1,6 @@
{ {
"name": "Fusion Shipping", "name": "Fusion Shipping",
"version": "19.0.1.5.0", "version": "19.0.1.7.0",
"category": "Inventory/Delivery", "category": "Inventory/Delivery",
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. " "summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
"Live pricing, label generation, shipment tracking, and multi-package support.", "Live pricing, label generation, shipment tracking, and multi-package support.",

View File

@@ -431,9 +431,11 @@ class UPSRequest:
(ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or (ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or
(ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR') (ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR')
): ):
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = { # reference_number is already a list of {'Code', 'Value'} dicts
'Value': shipment_info.get('reference_number') # (see _ups_rest_prepare_shipping_data). UPS expects ReferenceNumber
} # to be such an object (or an array of them) with a string Value --
# NOT {'Value': [<dict>, ...]}, which UPS rejects on the ship call.
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = shipment_info.get('reference_number')
# Shipments from US to CA or PR require extra info # Shipments from US to CA or PR require extra info
if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']: if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']:

View File

@@ -242,9 +242,12 @@ class DeliveryCarrier(models.Model):
('EPL', 'EPL'), ('EPL', 'EPL'),
('SPL', 'SPL')], ('SPL', 'SPL')],
string="UPS Label File Type", default='GIF') string="UPS Label File Type", default='GIF')
ups_bill_my_account = fields.Boolean(string='Bill My Account', ups_bill_my_account = fields.Boolean(string="Bill recipient's UPS account",
help="If checked, ecommerce users will be prompted their UPS account number\n" help="When the customer has a UPS account number on file (set on the "
"and delivery fees will be charged on it.") "customer's contact, Sales & Purchase tab), charge UPS shipping to "
"THEIR account instead of yours (UPS 'Bill Receiver'). The shipping "
"line on the order is set to $0 since the customer pays. Customers "
"with no account on file are billed to your account at the normal rate.")
ups_saturday_delivery = fields.Boolean(string='UPS Saturday Delivery', ups_saturday_delivery = fields.Boolean(string='UPS Saturday Delivery',
help='This value added service will allow you to ship the package on saturday also.') help='This value added service will allow you to ship the package on saturday also.')
ups_cod_funds_code = fields.Selection(selection=[ ups_cod_funds_code = fields.Selection(selection=[
@@ -261,6 +264,16 @@ class DeliveryCarrier(models.Model):
ups_access_token = fields.Char(string='UPS Access Token', groups="base.group_system") ups_access_token = fields.Char(string='UPS Access Token', groups="base.group_system")
ups_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type') ups_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type')
ups_require_signature = fields.Boolean("Require Signature") ups_require_signature = fields.Boolean("Require Signature")
ups_rest_documentation_type = fields.Selection(
[('none', 'No'),
('invoice', 'UPS commercial invoice (paperless if account enrolled)')],
string='Generate customs invoice', default='invoice',
help="For international shipments, have UPS generate the commercial invoice "
"from the order data (UPS 'International Forms') and attach the PDF to the "
"delivery. If your UPS account is enrolled in Paperless Invoice, UPS also "
"submits it electronically to customs/the recipient (and you avoid UPS's "
"paper-invoice surcharge). Requires an HS code and country of origin on the "
"shipped products. No invoice is generated for domestic shipments.")
# ══════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════
# FEDEX SOAP (Legacy) FIELDS # FEDEX SOAP (Legacy) FIELDS
@@ -1892,7 +1905,7 @@ class DeliveryCarrier(models.Model):
currency_code = picking.sale_id.currency_id.name currency_code = picking.sale_id.currency_id.name
shipment_info = { shipment_info = {
'require_invoice': picking._should_generate_commercial_invoice(), 'require_invoice': (self.ups_rest_documentation_type != 'none') and picking._should_generate_commercial_invoice(),
'invoice_date': fields.Date.today().strftime('%Y%m%d'), 'invoice_date': fields.Date.today().strftime('%Y%m%d'),
'description': picking.origin or picking.name, 'description': picking.origin or picking.name,
'total_qty': sum(sml.quantity for sml in picking.move_line_ids), 'total_qty': sum(sml.quantity for sml in picking.move_line_ids),

View File

@@ -7,5 +7,8 @@ class ResPartner(models.Model):
property_ups_carrier_account = fields.Char( property_ups_carrier_account = fields.Char(
string="UPS Account Number", string="UPS Account Number",
company_dependent=True, company_dependent=True,
help="UPS carrier account number for bill-my-account shipping.", help="The customer's own UPS account number. Stored on the customer and "
"recalled automatically when you ship to them with a UPS carrier that "
"has \"Bill recipient's UPS account\" enabled -- UPS then bills this "
"account for the shipping (UPS 'Bill Receiver') instead of yours.",
) )

View File

@@ -1,4 +1,4 @@
from odoo import models, fields from odoo import models, fields, api, _
class StockPicking(models.Model): class StockPicking(models.Model):
@@ -8,6 +8,20 @@ class StockPicking(models.Model):
string='Shipments', string='Shipments',
compute='_compute_fusion_shipment_count', compute='_compute_fusion_shipment_count',
) )
fusion_shipping_label_attachment_id = fields.Many2one(
'ir.attachment',
string='Shipping Label',
compute='_compute_fusion_shipping_docs',
help='Most recent shipping label generated for this delivery '
'(any carrier).',
)
fusion_commercial_invoice_attachment_id = fields.Many2one(
'ir.attachment',
string='Commercial Invoice',
compute='_compute_fusion_shipping_docs',
help='Most recent commercial (customs) invoice generated for this '
'delivery (any carrier).',
)
def _compute_fusion_shipment_count(self): def _compute_fusion_shipment_count(self):
Shipment = self.env['fusion.shipment'] Shipment = self.env['fusion.shipment']
@@ -16,6 +30,68 @@ class StockPicking(models.Model):
[('picking_id', '=', picking.id)] [('picking_id', '=', picking.id)]
) )
@api.depends('message_ids.attachment_ids')
def _compute_fusion_shipping_docs(self):
"""Find the latest shipping label + commercial invoice that the
carrier posted to this delivery's chatter, so they can be opened
from a smart button.
Naming is carrier-agnostic (see the carriers' send_shipping):
- labels contain 'Label' (Label-, LabelUPS, LabelShipping-...)
- invoices contain 'CommercialInvoice' (UPS/Canada Post) or start
with 'ShippingDoc-' (FedEx/DHL, via _get_delivery_doc_prefix).
"""
Attachment = self.env['ir.attachment']
for picking in self:
invoice = label = Attachment
if isinstance(picking.id, int):
atts = Attachment.search(
[('res_model', '=', 'stock.picking'),
('res_id', '=', picking.id)],
order='id desc')
for att in atts:
name = att.name or ''
norm = name.lower().replace(' ', '').replace('-', '')
if 'commercialinvoice' in norm or name.startswith('ShippingDoc'):
invoice = invoice or att
elif 'label' in name.lower() and 'return' not in name.lower():
label = label or att
if invoice and label:
break
picking.fusion_commercial_invoice_attachment_id = invoice.id
picking.fusion_shipping_label_attachment_id = label.id
def _fusion_open_attachment(self, attachment):
"""Open a shipping document for the operator.
Routes PDFs through ir.attachment.action_fusion_preview (preview
dialog) when fusion_pdf_preview is installed, else opens the file
in a new tab. Mirrors fusion.shipment._action_open_attachment.
See CLAUDE.md "PDF Preview".
"""
self.ensure_one()
if not attachment:
return False
if hasattr(attachment, 'action_fusion_preview'):
return attachment.action_fusion_preview(
title=attachment.name or _('Shipping Document'),
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=false' % attachment.id,
'target': 'new',
}
def action_view_fusion_shipping_label(self):
return self._fusion_open_attachment(
self.fusion_shipping_label_attachment_id)
def action_view_fusion_commercial_invoice(self):
return self._fusion_open_attachment(
self.fusion_commercial_invoice_attachment_id)
def action_view_fusion_shipments(self): def action_view_fusion_shipments(self):
self.ensure_one() self.ensure_one()
shipments = self.env['fusion.shipment'].search( shipments = self.env['fusion.shipment'].search(

View File

@@ -120,6 +120,8 @@
<field name="ups_require_signature"/> <field name="ups_require_signature"/>
<field name="ups_duty_payment" string="Duties paid by" <field name="ups_duty_payment" string="Duties paid by"
required="delivery_type == 'fusion_ups_rest'"/> required="delivery_type == 'fusion_ups_rest'"/>
<field name="ups_rest_documentation_type"
required="delivery_type == 'fusion_ups_rest'"/>
</group> </group>
</group> </group>
</group> </group>

View File

@@ -6,12 +6,28 @@
<field name="inherit_id" ref="stock.view_picking_form"/> <field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside"> <xpath expr="//div[@name='button_box']" position="inside">
<field name="fusion_shipping_label_attachment_id" invisible="1"/>
<field name="fusion_commercial_invoice_attachment_id" invisible="1"/>
<button name="action_view_fusion_shipments" type="object" <button name="action_view_fusion_shipments" type="object"
class="oe_stat_button" icon="fa-plane" class="oe_stat_button" icon="fa-plane"
invisible="fusion_shipment_count == 0"> invisible="fusion_shipment_count == 0">
<field name="fusion_shipment_count" widget="statinfo" <field name="fusion_shipment_count" widget="statinfo"
string="Shipments"/> string="Shipments"/>
</button> </button>
<button name="action_view_fusion_shipping_label" type="object"
class="oe_stat_button" icon="fa-print"
invisible="not fusion_shipping_label_attachment_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Shipping Label</span>
</div>
</button>
<button name="action_view_fusion_commercial_invoice" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not fusion_commercial_invoice_attachment_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Commercial Invoice</span>
</div>
</button>
</xpath> </xpath>
</field> </field>
</record> </record>

View File

@@ -781,7 +781,7 @@ class FusionTechnicianTask(models.Model):
def _inverse_datetime_start(self): def _inverse_datetime_start(self):
"""When datetime_start is changed (e.g. from calendar drag), update date + time.""" """When datetime_start is changed (e.g. from calendar drag), update date + time."""
import pytz import pytz
user_tz = pytz.timezone(self.env.user.tz or 'UTC') user_tz = self._get_local_tz()
for task in self: for task in self:
if task.datetime_start: if task.datetime_start:
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz) local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
@@ -791,7 +791,7 @@ class FusionTechnicianTask(models.Model):
def _inverse_datetime_end(self): def _inverse_datetime_end(self):
"""When datetime_end is changed (e.g. from calendar resize), update time_end.""" """When datetime_end is changed (e.g. from calendar resize), update time_end."""
import pytz import pytz
user_tz = pytz.timezone(self.env.user.tz or 'UTC') user_tz = self._get_local_tz()
for task in self: for task in self:
if task.datetime_end: if task.datetime_end:
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz) local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_task_tz

View File

@@ -0,0 +1,44 @@
# -*- 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()
# _compute_datetimes resolves company resource-calendar tz FIRST, then user tz.
# Set BOTH to Toronto so the UTC assertion and the round-trip are deterministic.
cls.env.user.tz = 'America/Toronto'
cal = cls.env.company.resource_calendar_id
if cal:
cal.tz = 'America/Toronto'
# technician_id is required (domain x_fc_is_field_staff=True) -> make a field tech.
cls.tech = cls.env['res.users'].create({
'name': 'TZ Test Tech',
'login': 'tz_test_tech_svcbook',
'x_fc_is_field_staff': True,
})
# A FUTURE date in July so the task is not "in the past" (the base
# _check_no_overlap constraint rejects past dates) and Toronto is firmly
# in EDT (-4), keeping the 9:00 -> 13:00 UTC assertion deterministic.
cls.task = cls.env['fusion.technician.task'].create({
'technician_id': cls.tech.id,
'scheduled_date': date(date.today().year + 1, 7, 1),
'time_start': 9.0,
'time_end': 10.0,
'description': 'TZ round-trip test', # description is required (NOT NULL)
'is_in_store': True, # avoids the address-required constraint
})
def test_local_to_utc_compute(self):
# 9:00 local Toronto (EDT, -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 recovers 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)

53
scripts/verify_service_booking.sh Normal file → Executable file
View File

@@ -37,7 +37,11 @@ PGPW="${PGPW:-DevSecure2025!}"
PGUSER="${PGUSER:-odoo}" PGUSER="${PGUSER:-odoo}"
MODULES="${MODULES:-fusion_tasks,fusion_claims}" # comma list for -u MODULES="${MODULES:-fusion_tasks,fusion_claims}" # comma list for -u
TEST_TAGS="${TEST_TAGS:-/fusion_tasks,/fusion_claims}" # Scope to THIS feature's test classes — the broad /fusion_claims tag also runs
# pre-existing dashboard/wizard tests that fail in this prod-config runner
# (CLAUDE.md fusion_repairs note: post_install trips on a pre-existing module),
# which is unrelated to this feature. Override TEST_TAGS to widen if desired.
TEST_TAGS="${TEST_TAGS:-/fusion_tasks:TestTaskTz,/fusion_claims:TestServiceRate,/fusion_claims:TestServiceBooking}"
MOD_DIRS=(fusion_tasks fusion_claims) # dirs to stage/deploy MOD_DIRS=(fusion_tasks fusion_claims) # dirs to stage/deploy
BRANCH="${BRANCH:-claude/technician-service-booking}" BRANCH="${BRANCH:-claude/technician-service-booking}"
@@ -91,17 +95,21 @@ dexec -e PGPASSWORD="$PGPW" "$DBC" sh -c \
>>"$LOG" 2>&1 >>"$LOG" 2>&1
ok "Cloned." ok "Cloned."
# ----------------------------- 2. ORPHAN-TAX-FK CLEANUP (clone only) --------- # ----------------------------- 2. ORPHANED-FK CLEANUP (clone only) -----------
# westin-v19 has ~3300 orphaned tax m2m rows under validated FKs; a plain # westin-v19 has orphaned rows under VALIDATED FKs (deleted taxes, companies,
# pg_dump|psql clone can't rebuild the validating FK over them -> Odoo fails to # journals, ...). A plain pg_dump|psql clone cannot rebuild a validating FK over
# load the registry. Safe to delete ON THE CLONE only. (CLAUDE.md gotcha.) # orphans, so the clone is MISSING those FKs; Odoo's check_foreign_keys then
c "Orphaned-tax-FK cleanup (clone only)" # re-adds them and fails (e.g. payslip_tags_table.res_company_id=3,
psql_clone -c "DELETE FROM product_taxes_rel WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true # account_payment_method_line.journal_id=35). Generate an orphan-delete for EVERY
psql_clone -c "DELETE FROM product_supplier_taxes_rel WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true # single-column FK that exists on PROD (read-only SELECT on prod) and apply it to
# sweep any other %_rel table carrying a tax_id column # the clone. The clone is a throwaway; prod is never modified.
psql_clone -t -A -c "SELECT table_name FROM information_schema.columns WHERE column_name='tax_id' AND table_name LIKE '%\\_rel';" 2>/dev/null \ # (CLAUDE.md orphan-FK gotcha, generalised beyond the tax tables.)
| while read -r t; do [[ -n "$t" ]] && psql_clone -c "DELETE FROM ${t} WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true; done c "Orphaned-FK cleanup (clone only) — general sweep from prod's FK definitions"
ok "Orphan FKs cleared on clone." FKSQL="/tmp/svcbook_fkclean_${STAMP}.sql"
printf '%s\n' '\set ON_ERROR_STOP off' > "$FKSQL"
dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$PROD_DB" -t -A -c "SELECT format('DELETE FROM %I a WHERE a.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I b WHERE b.%I = a.%I);', src.relname, srcatt.attname, tgt.relname, tgtatt.attname, srcatt.attname) FROM pg_constraint con JOIN pg_class src ON src.oid=con.conrelid JOIN pg_namespace ns ON ns.oid=src.relnamespace AND ns.nspname='public' JOIN pg_class tgt ON tgt.oid=con.confrelid JOIN pg_attribute srcatt ON srcatt.attrelid=con.conrelid AND srcatt.attnum=con.conkey[1] JOIN pg_attribute tgtatt ON tgtatt.attrelid=con.confrelid AND tgtatt.attnum=con.confkey[1] WHERE con.contype='f' AND array_length(con.conkey,1)=1;" >> "$FKSQL" 2>>"$LOG" || true
dexec -i -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$CLONE_DB" < "$FKSQL" >>"$LOG" 2>&1 || true
ok "Orphan FKs cleared on clone (general sweep, $(grep -c '^DELETE' "$FKSQL" 2>/dev/null || echo 0) FK relations)."
# ----------------------------- 3. STAGE MODULES (shadow) --------------------- # ----------------------------- 3. STAGE MODULES (shadow) ---------------------
c "Stage modules into $STAGE (shadows prod, prod files untouched)" c "Stage modules into $STAGE (shadows prod, prod files untouched)"
@@ -114,9 +122,12 @@ ok "Staged: ${MOD_DIRS[*]}"
# --test-enable SILENTLY SKIPS without --workers 0; log_level=warn hides test # --test-enable SILENTLY SKIPS without --workers 0; log_level=warn hides test
# output -> add --log-level=test. The EXIT CODE is authoritative. # output -> add --log-level=test. The EXIT CODE is authoritative.
run_odoo() { # $1 = extra args run_odoo() { # $1 = extra args
# --test-enable forces http_spawn() even with --no-http (Odoo 19), so the test
# run binds 8069 (held by the live app) and dies with "Address already in use".
# --http-port=0 --gevent-port=0 makes it pick ephemeral ports. (CLAUDE.md gotcha.)
dexec "$APP" odoo -d "$CLONE_DB" \ dexec "$APP" odoo -d "$CLONE_DB" \
--db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \ --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \
--addons-path="$ADDONS_PATH" --stop-after-init --no-http $1 --addons-path="$ADDONS_PATH" --stop-after-init --no-http --http-port=0 --gevent-port=0 $1
} }
c "Install/upgrade on clone (catches install/render errors)" c "Install/upgrade on clone (catches install/render errors)"
@@ -129,6 +140,22 @@ else
TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true
fi fi
# Asset-bundle compile check: a broken SCSS/SASS breaks the ENTIRE
# web.assets_backend bundle (the whole backend UI for every user), and `-u` does
# NOT compile it — Odoo compiles assets lazily at request time. Force-compile
# both bundles here so a stylesheet error fails the gate BEFORE prod, not after.
# (CLAUDE.md asset cache-busting #3.)
if [[ "${TESTS_OK:-0}" == "1" ]]; then
c "Compile asset bundles on clone (catches SCSS errors)"
echo "env['ir.qweb']._get_asset_bundle('web.assets_backend').css(); env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css(); print('ASSETS_COMPILED_OK')" \
| dexec -i "$APP" odoo shell -d "$CLONE_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" --addons-path="$ADDONS_PATH" --no-http --http-port=0 --gevent-port=0 >>"$LOG" 2>&1 || true
if grep -q ASSETS_COMPILED_OK "$LOG"; then
ok "Asset bundles compiled OK"
else
TESTS_OK=0; err "ASSET COMPILE FAILED — see $LOG"; grep -iE 'error|scss|sass|Traceback' "$LOG" | tail -25 || true
fi
fi
echo echo
c "VERIFY RESULT" c "VERIFY RESULT"
if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi