Compare commits

...

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.9.5.0',
'version': '19.0.9.7.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """

View File

@@ -30,6 +30,44 @@ class ServiceBookingController(http.Controller):
'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:

View File

@@ -1,5 +1,5 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
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";
@@ -11,10 +11,12 @@ export class ServiceBookingWizard extends Component {
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: "" },
partnerId: false, soSearch: "",
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,
@@ -31,6 +33,104 @@ export class ServiceBookingWizard extends Component {
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;

View File

@@ -66,26 +66,17 @@
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
.body { padding: 20px 24px 6px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
@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; }
}
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card {
.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);
}
.card.span2 { grid-column: 1 / -1; }
.card h3 {
.sb-card.span2 { grid-column: 1 / -1; }
.sb-card h3 {
margin: 0 0 13px;
font-size: 11.5px;
font-weight: 700;
@@ -96,8 +87,8 @@
align-items: center;
gap: 7px;
}
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
.card h3 .tag {
.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;
@@ -109,8 +100,8 @@
}
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
.row { margin-bottom: 12px; }
.row:last-child { margin-bottom: 0; }
.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; }
@@ -139,6 +130,28 @@
.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);
@@ -276,7 +289,7 @@
}
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
.btn {
.sb-btn {
border: none;
border-radius: 10px;
padding: 11px 18px;
@@ -285,13 +298,32 @@
cursor: pointer;
font-family: inherit;
}
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
.btn.primary {
.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);
}
.btn[disabled] { opacity: .6; cursor: not-allowed; }
.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

@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
<div class="o_service_booking">
<div class="o_service_booking" t-ref="root">
<div class="wrap">
<div class="dialog">
<div class="topbar">
@@ -20,11 +20,11 @@
</div>
<div class="body">
<div class="grid">
<div class="sb-grid">
<!-- CUSTOMER -->
<div class="card">
<div class="sb-card">
<h3><span class="dot"></span>Customer</h3>
<div class="row">
<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>
@@ -33,22 +33,34 @@
</div>
</div>
<div t-if="state.custMode === 'existing'">
<div class="row">
<div class="sb-row sb-cust-search">
<label class="fl">Search by phone, name or SO</label>
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
<div class="hint">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
<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="row two">
<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="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
<div class="row"><label class="fl">Address</label>
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></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="row three">
<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>
@@ -58,9 +70,9 @@
</div>
<!-- SERVICE & PRICING -->
<div class="card">
<div class="sb-card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="row two">
<div class="sb-row two">
<div>
<label class="fl">Device being serviced</label>
<select class="f" t-on-change="onDevice">
@@ -79,7 +91,7 @@
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
</div>
</div>
<div class="row" t-if="!state.inShop">
<div class="sb-row" t-if="!state.inShop">
<label class="fl">Service call type</label>
<select class="f"
t-on-change="onCallType">
@@ -100,9 +112,9 @@
</div>
<!-- SCHEDULE -->
<div class="card">
<div class="sb-card">
<h3><span class="dot"></span>Schedule</h3>
<div class="row two">
<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">
@@ -114,7 +126,7 @@
</select>
</div>
</div>
<div class="row">
<div class="sb-row">
<label class="fl">Start time</label>
<div class="timepick">
<select class="f" t-model.number="state.hour">
@@ -140,7 +152,7 @@
</div>
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
</div>
<div class="row">
<div class="sb-row">
<label class="fl">Technician</label>
<select class="f" t-model.number="state.technicianId">
<option value="">— Choose —</option>
@@ -152,17 +164,17 @@
</div>
<!-- LOCATION -->
<div class="card">
<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="row"><label class="fl">Job address</label>
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
<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="row two">
<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>
@@ -170,11 +182,11 @@
</div>
<!-- JOB DETAILS -->
<div class="card span2">
<div class="sb-card span2">
<h3><span class="dot"></span>Job details</h3>
<div class="two">
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
<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>
@@ -197,8 +209,8 @@
<div class="foot">
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book &amp; Create SO</button>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,13 @@ def _clean(text):
t = str(text)
for a, b in ((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)
return t.strip()
@@ -88,6 +94,9 @@ class FpJob(models.Model):
'qty': qty,
'due': due_s,
'thk': thk,
# Real thickness present (has a digit) — drives the prominent
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',

View File

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

View File

@@ -57,8 +57,11 @@
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<!-- NOT bound to the fp.job Print menu (removed from the list per
request). Printed via the Receiving screen button
(fp.receiving.action_print_internal_sticker). eval=False clears
any binding a prior install left in the DB. -->
<field name="binding_model_id" eval="False"/>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
@@ -84,13 +87,13 @@
/* Layout B rail + main */
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
.r-logo { height: 11mm; line-height: 11mm; text-align: center; }
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
.r-wo { height: 14mm; background: #000; color: #fff; padding: 0; }
.r-logo { height: 9mm; line-height: 9mm; text-align: center; }
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
.r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
.r-qrflags { height: 36mm; text-align: center; }
.r-qrflags { height: 32mm; text-align: center; }
.qftbl { border-collapse: collapse; width: 100%; height: 100%; }
.qftbl td { vertical-align: middle; text-align: center; }
.qfqr { width: 66%; }
@@ -104,72 +107,23 @@
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
.qftags { width: 34%; border-left: 0.5mm solid #000; }
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
.qffull { line-height: 36mm; }
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; }
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; }
/* Internal (Layout A) header QR — same ~10% quiet-zone crop, bigger box. */
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; }
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; }
.r-fld { padding: 1mm 2.2mm; }
.qffull { line-height: 32mm; }
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
/* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
30mm -> 27mm so the QR-driven black band is shorter and the freed
height flows to the NOTES block below. Still well above scan size. */
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
.qfwrap-int img { position: absolute; width: 33.75mm; height: 33.75mm; top: -3.375mm; left: -3.375mm; }
.r-fld { padding: 0.7mm 2.2mm; }
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.m-thk { padding: 1.8mm 2.6mm 2.4mm; }
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
</style>
</template>
<!-- ===================== Internal body — Layout A ===================== -->
<template id="fp_job_internal_body">
<div class="label-page"><div class="label">
<table class="fpt">
<tr style="height:22mm" class="rule">
<td class="band pad">
<span style="float:right"><span class="tag">INTERNAL</span></span>
<span class="lbl" style="color:#fff">Work Order</span>
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
</td>
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;vertical-align:middle;padding:1mm">
<span class="qfwrap-int"><img t-att-src="_qr"/></span>
</td>
</tr>
<tr style="height:12mm" class="rule">
<td class="pad" colspan="2">
<span class="lbl">Part#</span>
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
<span style="float:right">
<t t-if="d['mask']"><span class="badge">MASK</span></t>
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
</span>
</td>
</tr>
<tr style="height:10mm" class="rule">
<td style="padding:0" colspan="2"><table class="fpt"><tr>
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></td>
</tr>
<t t-if="d['bake']">
<tr style="height:13mm" class="rule">
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
<span class="inshead">BAKE</span>
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
</td>
</tr>
</t>
<tr>
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
<span class="inshead">NOTES</span>
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
</td>
</tr>
</table>
</div></div>
</template>
<!-- ===================== External body — Layout B ===================== -->
<template id="fp_job_external_body">
<div class="label-page"><div class="label">
@@ -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>
</tr></table></div>
<div style="height:8.5mm"><table class="gtbl"><tr>
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
<td><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
</tr></table></div>
</div>
<div class="main">
<!-- Plating thickness — the team's most-watched spec. Relocated
out of the cramped rail to a prominent full-width banner at
the top of the main panel. -->
<t t-if="d['has_thk']">
<div class="m-thk rule"><span class="inshead">PLATING THICKNESS</span>
<div style="font-size:21pt;font-weight:900;line-height:1;margin-top:1.4mm"><t t-esc="d['thk']"/></div>
</div>
</t>
<t t-if="d['bake']">
<div class="m-bake rule"><span class="inshead">BAKE</span>
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
</div>
</t>
<div class="m-notes"><span class="inshead">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>
</div>
@@ -233,16 +194,35 @@
</template>
<!-- ===================== Internal outer (per job) ===================== -->
<!-- Internal sticker = a COPY of the External (Layout B, one per box) but
showing the INTERNAL description instead of the customer-facing notes,
and a "INTERNAL NOTES" header so the shop copy can't be confused with
the customer copy. Identical rail/QR/logo/box otherwise. -->
<template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container">
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
<t t-foreach="docs" t-as="job">
<t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['internal_notes']"/>
<t t-set="_notes_label" t-value="'INTERNAL NOTES'"/>
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="_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-set="boxes" t-value="job._fp_sticker_boxes()"/>
<t t-if="boxes">
<t t-foreach="boxes" t-as="box">
<t t-set="_box_num" t-value="box.box_number"/>
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t>
<t t-else="">
<t t-set="_box_num" t-value="1"/>
<t t-set="_box_cnt" t-value="1"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t>
</t>
</template>
@@ -254,6 +234,7 @@
<t t-foreach="docs" t-as="job">
<t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['customer_notes']"/>
<t t-set="_notes_label" t-value="'NOTES'"/>
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,15 @@ class SaleOrder(models.Model):
'expected_qty': int(total_qty),
'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
def action_view_receiving(self):

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.11.34.0',
'version': '19.0.11.36.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [
@@ -25,6 +25,11 @@
# creates a cycle. Our only fp.job touchpoint is wo_scan.py which
# uses runtime env.get('fp.job') — safe without the manifest dep.
'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': [
'security/ir.model.access.csv',

View File

@@ -216,7 +216,7 @@
</div>
</td>
<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>
<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 ''"/>

View File

@@ -461,4 +461,271 @@
</t>
</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>

View File

@@ -1,6 +1,6 @@
{
"name": "Fusion Shipping",
"version": "19.0.1.5.0",
"version": "19.0.1.7.0",
"category": "Inventory/Delivery",
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
"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 == 'PR' and ship_to.country_id.code == 'PR')
):
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = {
'Value': shipment_info.get('reference_number')
}
# reference_number is already a list of {'Code', 'Value'} dicts
# (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
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'),
('SPL', 'SPL')],
string="UPS Label File Type", default='GIF')
ups_bill_my_account = fields.Boolean(string='Bill My Account',
help="If checked, ecommerce users will be prompted their UPS account number\n"
"and delivery fees will be charged on it.")
ups_bill_my_account = fields.Boolean(string="Bill recipient's UPS account",
help="When the customer has a UPS account number on file (set on the "
"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',
help='This value added service will allow you to ship the package on saturday also.')
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_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type')
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
@@ -1892,7 +1905,7 @@ class DeliveryCarrier(models.Model):
currency_code = picking.sale_id.currency_id.name
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'),
'description': picking.origin or picking.name,
'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(
string="UPS Account Number",
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):
@@ -8,6 +8,20 @@ class StockPicking(models.Model):
string='Shipments',
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):
Shipment = self.env['fusion.shipment']
@@ -16,6 +30,68 @@ class StockPicking(models.Model):
[('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):
self.ensure_one()
shipments = self.env['fusion.shipment'].search(

View File

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

View File

@@ -6,12 +6,28 @@
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<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"
class="oe_stat_button" icon="fa-plane"
invisible="fusion_shipment_count == 0">
<field name="fusion_shipment_count" widget="statinfo"
string="Shipments"/>
</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>
</field>
</record>