Compare commits

...

10 Commits

Author SHA1 Message Date
gsinghpal
53fe13344d fix(fusion_claims): service-booking wizard responsive — namespace Bootstrap classes + reorder media queries
The 19.0.9.5.0 CSS pass (padding/scroll) did not fix "fields sitting on each
other" on small screens. Deep dive against the live web.assets_backend bundle
found two compounding root causes (both measured, not guessed):

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

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

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

Bump fusion_claims 19.0.9.5.0 -> 19.0.9.6.0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:51:44 -04:00
24 changed files with 429 additions and 101 deletions

View File

@@ -3104,3 +3104,40 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
- Add new gotchas in the right format - Add new gotchas in the right format
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview` - Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
- Know the deployment fact that fusion_portal is always co-installed - Know the deployment fact that fusion_portal is always co-installed
## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`)
The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`),
so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this
wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix
the real cause — verify with a render, not by eye):
1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.**
The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under
`.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex;
margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`,
`.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property
the scoped rule doesn't set). Measured live: every wizard `.row` computed
`display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields
to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are
`sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class
that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …).
2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).**
SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{
grid-template-columns:1fr } … }` was nested high in the file, *before* the base
`.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have
the same specificity, so the later base rule overrode the media query — it was **dead**,
and the inner field-grids never collapsed to one column on a phone (fields crammed 23
across). `matchMedia('(max-width:560px)')` returned true while the columns stayed 2-up —
the tell that it's a cascade-order bug, not a media-match bug. **Fix:** all responsive
`@media` overrides live at the **end** of the `.o_service_booking { … }` block.
**How it was verified (do this, don't eyeball):** pull the live compiled bundle from prod
(`env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` returns `ir.attachment`
record(s) in Odoo 19 — read `.raw`, not a string), render the wizard markup against it with
the real web-client height/scroll chain (`html,body{height:100%}` → `.o_web_client` flex
column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard's
`height:100%;overflow:auto` scrolls) at 320/390/768/1280, and read computed
`grid-template-columns` / `margin-left` / `display`. A standalone vanilla-Bootstrap repro is
**not** faithful — it rendered fine and falsely cleared the bug.

View File

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

View File

@@ -16,7 +16,10 @@
color: var(--sb-text); color: var(--sb-text);
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif; font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
font-size: 14px; font-size: 14px;
min-height: 100%; // Fill the action area and scroll INTERNALLY. min-height let the root grow
// to its content height so the (clipping) action container never scrolled;
// height:100% caps it so overflow:auto engages on small screens.
height: 100%;
overflow: auto; overflow: auto;
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -63,18 +66,17 @@
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); } .step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
.body { padding: 20px 24px 6px; } .body { padding: 20px 24px 6px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
.card { .sb-card {
background: var(--sb-card); background: var(--sb-card);
border: 1px solid var(--sb-border); border: 1px solid var(--sb-border);
border-radius: 13px; border-radius: 13px;
padding: 16px 17px; padding: 16px 17px;
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
} }
.card.span2 { grid-column: 1 / -1; } .sb-card.span2 { grid-column: 1 / -1; }
.card h3 { .sb-card h3 {
margin: 0 0 13px; margin: 0 0 13px;
font-size: 11.5px; font-size: 11.5px;
font-weight: 700; font-weight: 700;
@@ -85,8 +87,8 @@
align-items: center; align-items: center;
gap: 7px; gap: 7px;
} }
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); } .sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
.card h3 .tag { .sb-card h3 .tag {
margin-left: auto; margin-left: auto;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -98,8 +100,8 @@
} }
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; } label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
.row { margin-bottom: 12px; } .sb-row { margin-bottom: 12px; }
.row:last-child { margin-bottom: 0; } .sb-row:last-child { margin-bottom: 0; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; } .two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; } .three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
@@ -109,8 +111,11 @@
color: var(--sb-text); color: var(--sb-text);
border: 1px solid var(--sb-field-border); border: 1px solid var(--sb-field-border);
border-radius: 9px; border-radius: 9px;
padding: 9px 11px; // !important so Odoo's backend input normalisation can't strip the
// field padding inside a client action.
padding: 10px 12px !important;
font-size: 13.5px; font-size: 13.5px;
line-height: 1.4;
font-family: inherit; font-family: inherit;
outline: none; outline: none;
transition: border .15s, box-shadow .15s; transition: border .15s, box-shadow .15s;
@@ -262,7 +267,7 @@
} }
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); } .foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
.btn { .sb-btn {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 11px 18px; padding: 11px 18px;
@@ -271,13 +276,32 @@
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
} }
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); } .sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
.btn.primary { .sb-btn.primary {
color: #fff; color: #fff;
background: linear-gradient(135deg, #5ba848, #2e7aad); background: linear-gradient(135deg, #5ba848, #2e7aad);
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent); 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; } .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

@@ -20,11 +20,11 @@
</div> </div>
<div class="body"> <div class="body">
<div class="grid"> <div class="sb-grid">
<!-- CUSTOMER --> <!-- CUSTOMER -->
<div class="card"> <div class="sb-card">
<h3><span class="dot"></span>Customer</h3> <h3><span class="dot"></span>Customer</h3>
<div class="row"> <div class="sb-row">
<div class="seg full"> <div class="seg full">
<button t-att-class="{ on: state.custMode === 'existing' }" <button t-att-class="{ on: state.custMode === 'existing' }"
t-on-click="() => this.setCust('existing')">Existing customer</button> t-on-click="() => this.setCust('existing')">Existing customer</button>
@@ -33,22 +33,22 @@
</div> </div>
</div> </div>
<div t-if="state.custMode === 'existing'"> <div t-if="state.custMode === 'existing'">
<div class="row"> <div class="sb-row">
<label class="fl">Search by phone, name or SO</label> <label class="fl">Search by phone, name or SO</label>
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/> <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> <div class="hint">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
</div> </div>
</div> </div>
<div t-if="state.custMode === 'new'"> <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">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><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
</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="sb-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="sb-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="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
</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">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">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div> <div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
@@ -58,9 +58,9 @@
</div> </div>
<!-- SERVICE & PRICING --> <!-- SERVICE & PRICING -->
<div class="card"> <div class="sb-card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3> <h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="row two"> <div class="sb-row two">
<div> <div>
<label class="fl">Device being serviced</label> <label class="fl">Device being serviced</label>
<select class="f" t-on-change="onDevice"> <select class="f" t-on-change="onDevice">
@@ -79,7 +79,7 @@
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/> <input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
</div> </div>
</div> </div>
<div class="row" t-if="!state.inShop"> <div class="sb-row" t-if="!state.inShop">
<label class="fl">Service call type</label> <label class="fl">Service call type</label>
<select class="f" <select class="f"
t-on-change="onCallType"> t-on-change="onCallType">
@@ -100,9 +100,9 @@
</div> </div>
<!-- SCHEDULE --> <!-- SCHEDULE -->
<div class="card"> <div class="sb-card">
<h3><span class="dot"></span>Schedule</h3> <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">Date</label><input class="f" type="date" t-model="state.date"/></div>
<div><label class="fl">Duration</label> <div><label class="fl">Duration</label>
<select class="f" t-model.number="state.durationHr"> <select class="f" t-model.number="state.durationHr">
@@ -114,7 +114,7 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row"> <div class="sb-row">
<label class="fl">Start time</label> <label class="fl">Start time</label>
<div class="timepick"> <div class="timepick">
<select class="f" t-model.number="state.hour"> <select class="f" t-model.number="state.hour">
@@ -140,7 +140,7 @@
</div> </div>
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div> <div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
</div> </div>
<div class="row"> <div class="sb-row">
<label class="fl">Technician</label> <label class="fl">Technician</label>
<select class="f" t-model.number="state.technicianId"> <select class="f" t-model.number="state.technicianId">
<option value="">— Choose —</option> <option value="">— Choose —</option>
@@ -152,17 +152,17 @@
</div> </div>
<!-- LOCATION --> <!-- LOCATION -->
<div class="card"> <div class="sb-card">
<h3><span class="dot"></span>Location</h3> <h3><span class="dot"></span>Location</h3>
<div class="opt" style="border:none; padding-top:0;"> <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="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 class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
</div> </div>
<div t-if="!state.inShop"> <div t-if="!state.inShop">
<div class="row"><label class="fl">Job address</label> <div class="sb-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="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
</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">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><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
</div> </div>
@@ -170,11 +170,11 @@
</div> </div>
<!-- JOB DETAILS --> <!-- JOB DETAILS -->
<div class="card span2"> <div class="sb-card span2">
<h3><span class="dot"></span>Job details</h3> <h3><span class="dot"></span>Job details</h3>
<div class="two"> <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="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="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">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
</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">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div> <div class="opt"><div class="lab">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 +197,8 @@
<div class="foot"> <div class="foot">
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span> <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="sb-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 primary" t-on-click="submit" t-att-disabled="state.saving">Book &amp; Create SO</button>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -58,11 +58,18 @@ class FpCertificate(models.Model):
string='Customer Job No.', string='Customer Job No.',
help="Customer's internal job / traveler reference.", help="Customer's internal job / traveler reference.",
) )
contact_partner_id = fields.Many2one( contact_partner_ids = fields.Many2many(
'res.partner', string='Customer Contact', 'res.partner',
relation='fp_certificate_contact_partner_rel',
column1='cert_id', column2='partner_id',
string='Customer Contact',
domain="[('parent_id', '=', partner_id)]", domain="[('parent_id', '=', partner_id)]",
help="Specific contact person at the customer for this certificate. " help="Contacts at the customer who receive this certificate. "
'Their name, email, and phone are printed on the CoC.', "Auto-filled from the customer's Default CoC Contacts when a "
'job ships. The first is the primary (its name, email and '
'phone print on the CoC); ALL of them are emailed when the '
'cert is sent to the customer. (Renamed from the single '
'contact_partner_id — see migration 19.0.10.3.0.)',
) )
issued_by_id = fields.Many2one( issued_by_id = fields.Many2one(
'res.users', string='Issued By', default=lambda self: self.env.user, 'res.users', string='Issued By', default=lambda self: self.env.user,
@@ -519,12 +526,15 @@ class FpCertificate(models.Model):
# was configured would still trip the gate even after sales # was configured would still trip the gate even after sales
# set the default. Robust-by-construction: the defaults take # set the default. Robust-by-construction: the defaults take
# effect retroactively at issue time. # effect retroactively at issue time.
if (not rec.contact_partner_id if (not rec.contact_partner_ids
and rec.partner_id and rec.partner_id
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
and rec.partner_id.x_fc_default_coc_contact_id): and rec.partner_id.x_fc_default_coc_contact_ids):
rec.contact_partner_id = ( # Auto-fill ALL the customer's CoC contacts. The first is
rec.partner_id.x_fc_default_coc_contact_id # the primary (printed on the CoC); every contact is emailed
# when the cert is sent (action_send_to_customer).
rec.contact_partner_ids = (
rec.partner_id.x_fc_default_coc_contact_ids
) )
# Lazy-fill the signer from the LIVE company owner (Settings # Lazy-fill the signer from the LIVE company owner (Settings
# "Certificate Owner") when no per-cert / per-spec signer was # "Certificate Owner") when no per-cert / per-spec signer was
@@ -578,27 +588,27 @@ class FpCertificate(models.Model):
'(Settings > Fusion Plating).' '(Settings > Fusion Plating).'
) % {'name': rec.name or rec.display_name}) ) % {'name': rec.name or rec.display_name})
# Customer contact — the named recipient printed on the # Customer contact — the named recipient printed on the
# cert and emailed when it ships. Auto-filled from # cert and emailed when it ships. Auto-filled from the FIRST
# partner.x_fc_default_coc_contact_id when set. # of partner.x_fc_default_coc_contact_ids when set.
if not rec.contact_partner_id: if not rec.contact_partner_ids:
raise UserError(_( raise UserError(_(
'Cannot issue certificate "%(name)s" — Customer ' 'Cannot issue certificate "%(name)s" — Customer '
'Contact is not set.\n\nPick the recipient contact, ' 'Contact is not set.\n\nPick the recipient contact(s), '
'or configure a Default CoC Contact on customer ' 'or configure Default CoC Contacts on customer '
'"%(cust)s".' '"%(cust)s".'
) % { ) % {
'name': rec.name or rec.display_name, 'name': rec.name or rec.display_name,
'cust': rec.partner_id.name if rec.partner_id else '?', 'cust': rec.partner_id.name if rec.partner_id else '?',
}) })
if not (rec.contact_partner_id.email or '').strip(): if not (rec.contact_partner_ids[:1].email or '').strip():
raise UserError(_( raise UserError(_(
'Cannot issue certificate "%(name)s" — contact ' 'Cannot issue certificate "%(name)s" primary contact '
'"%(c)s" has no email address.\n\nAdd an email ' '"%(c)s" has no email address.\n\nAdd an email to the '
'to the contact before issuing (the cert is sent ' 'contact before issuing (the cert is sent by email '
'by email post-issue).' 'post-issue).'
) % { ) % {
'name': rec.name or rec.display_name, 'name': rec.name or rec.display_name,
'c': rec.contact_partner_id.name, 'c': rec.contact_partner_ids[:1].name,
}) })
# Orphan cert types (Nadcap / Mill Test / Customer-Specific) # Orphan cert types (Nadcap / Mill Test / Customer-Specific)
# are manual-attach only — operator uploads supplier doc / # are manual-attach only — operator uploads supplier doc /
@@ -1114,11 +1124,18 @@ class FpCertificate(models.Model):
"""Open email composer with the certificate PDF attached.""" """Open email composer with the certificate PDF attached."""
self.ensure_one() self.ensure_one()
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False) template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
# Send to ALL the Customer Contacts defined on this cert (auto-filled
# from the customer's Default CoC Contacts at job creation), falling
# back to the company. The CoC goes to exactly the contacts shown on
# the cert's Customer Contact field.
recipients = self.contact_partner_ids
partner_ids = recipients.ids or (
[self.partner_id.id] if self.partner_id else [])
ctx = { ctx = {
'default_model': 'fp.certificate', 'default_model': 'fp.certificate',
'default_res_ids': self.ids, 'default_res_ids': self.ids,
'default_composition_mode': 'comment', 'default_composition_mode': 'comment',
'default_partner_ids': [self.partner_id.id] if self.partner_id else [], 'default_partner_ids': partner_ids,
} }
if self.attachment_id: if self.attachment_id:
ctx['default_attachment_ids'] = [self.attachment_id.id] ctx['default_attachment_ids'] = [self.attachment_id.id]

View File

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

View File

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

View File

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

View File

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

View File

@@ -2678,7 +2678,7 @@ class FpJob(models.Model):
(a per-spec override). Left empty (a per-spec override). Left empty
otherwise so the LIVE company owner otherwise so the LIVE company owner
resolves at render / issue time. resolves at render / issue time.
- contact_partner_id ← partner.x_fc_default_coc_contact_id - contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects - nc_quantity ← qty_scrapped + qty_visual_insp_rejects
Honours part.certificate_requirement (coc / coc_thickness / Honours part.certificate_requirement (coc / coc_thickness /
@@ -2713,8 +2713,11 @@ class FpJob(models.Model):
signer = spec.signer_user_id signer = spec.signer_user_id
# Contact: per-customer default; blank means manager picks at issue. # Contact: per-customer default; blank means manager picks at issue.
contact = False contact = False
if 'x_fc_default_coc_contact_id' in self.partner_id._fields: if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
contact = self.partner_id.x_fc_default_coc_contact_id # ALL the customer's CoC contacts -> the cert's Customer Contact
# M2m. First is the primary (printed on the CoC); every contact
# is emailed at send (fp.certificate.action_send_to_customer).
contact = self.partner_id.x_fc_default_coc_contact_ids
# NC qty: scrapped + visual rejects. Both NULL-safe. # NC qty: scrapped + visual rejects. Both NULL-safe.
nc_qty = int( nc_qty = int(
(self.qty_scrapped or 0) (self.qty_scrapped or 0)
@@ -2784,8 +2787,8 @@ class FpJob(models.Model):
vals['process_description'] = recipe.name or '' vals['process_description'] = recipe.name or ''
if 'certified_by_id' in Cert._fields and signer: if 'certified_by_id' in Cert._fields and signer:
vals['certified_by_id'] = signer.id vals['certified_by_id'] = signer.id
if 'contact_partner_id' in Cert._fields and contact: if 'contact_partner_ids' in Cert._fields and contact:
vals['contact_partner_id'] = contact.id vals['contact_partner_ids'] = [(6, 0, contact.ids)]
if 'entech_wo_number' in Cert._fields: if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or '' vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals) cert = Cert.create(vals)

View File

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

View File

@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
'certificate_type': 'nadcap_cert', 'certificate_type': 'nadcap_cert',
'state': 'draft', 'state': 'draft',
'partner_id': self.partner.id, 'partner_id': self.partner.id,
'contact_partner_id': self.partner.id, 'contact_partner_ids': [(6, 0, [self.partner.id])],
'spec_reference': 'AMS 2404', 'spec_reference': 'AMS 2404',
'process_description': 'TEST PROCESS', 'process_description': 'TEST PROCESS',
'certified_by_id': self.env.user.id, 'certified_by_id': self.env.user.id,

View File

@@ -216,7 +216,7 @@
</div> </div>
</td> </td>
<td style="width: 50%; vertical-align: top;"> <td style="width: 50%; vertical-align: top;">
<t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/> <t t-set="contact" t-value="doc.contact_partner_ids[:1] or doc.partner_id"/>
<div> <div>
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>: <span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
<t t-esc="contact.name or ''"/> <t t-esc="contact.name or ''"/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,6 +140,22 @@ else
TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true
fi fi
# Asset-bundle compile check: a broken SCSS/SASS breaks the ENTIRE
# web.assets_backend bundle (the whole backend UI for every user), and `-u` does
# NOT compile it — Odoo compiles assets lazily at request time. Force-compile
# both bundles here so a stylesheet error fails the gate BEFORE prod, not after.
# (CLAUDE.md asset cache-busting #3.)
if [[ "${TESTS_OK:-0}" == "1" ]]; then
c "Compile asset bundles on clone (catches SCSS errors)"
echo "env['ir.qweb']._get_asset_bundle('web.assets_backend').css(); env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css(); print('ASSETS_COMPILED_OK')" \
| dexec -i "$APP" odoo shell -d "$CLONE_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" --addons-path="$ADDONS_PATH" --no-http --http-port=0 --gevent-port=0 >>"$LOG" 2>&1 || true
if grep -q ASSETS_COMPILED_OK "$LOG"; then
ok "Asset bundles compiled OK"
else
TESTS_OK=0; err "ASSET COMPILE FAILED — see $LOG"; grep -iE 'error|scss|sass|Traceback' "$LOG" | tail -25 || true
fi
fi
echo echo
c "VERIFY RESULT" c "VERIFY RESULT"
if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi