Compare commits
10 Commits
dcd4955bb7
...
53fe13344d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fe13344d | ||
|
|
423f288507 | ||
|
|
a86f20017d | ||
|
|
7426501555 | ||
|
|
3e787a1b24 | ||
|
|
6f006e24ad | ||
|
|
ba6aeaaca9 | ||
|
|
27577dd51a | ||
|
|
a10b7425f7 | ||
|
|
a2277b481c |
@@ -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 2–3
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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': """
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & their history.</div>
|
<div class="hint">Inbound call? Type the phone number — we match the contact & 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 & Pricing<span class="tag">$ REVENUE</span></h3>
|
<h3><span class="dot"></span>Service & 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 & Create SO</button>
|
<button class="sb-btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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': """
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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+)"/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ''"/>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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']:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user