Compare commits
11 Commits
dcd4955bb7
...
claude/ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d06ff77f | ||
|
|
53fe13344d | ||
|
|
423f288507 | ||
|
|
a86f20017d | ||
|
|
7426501555 | ||
|
|
3e787a1b24 | ||
|
|
6f006e24ad | ||
|
|
ba6aeaaca9 | ||
|
|
27577dd51a | ||
|
|
a10b7425f7 | ||
|
|
a2277b481c |
@@ -3104,3 +3104,72 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
|
||||
- Add new gotchas in the right format
|
||||
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
||||
- Know the deployment fact that fusion_portal is always co-installed
|
||||
|
||||
## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`)
|
||||
|
||||
The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`),
|
||||
so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this
|
||||
wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix
|
||||
the real cause — verify with a render, not by eye):
|
||||
|
||||
1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.**
|
||||
The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under
|
||||
`.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex;
|
||||
margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`,
|
||||
`.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property
|
||||
the scoped rule doesn't set). Measured live: every wizard `.row` computed
|
||||
`display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields
|
||||
to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are
|
||||
`sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class
|
||||
that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …).
|
||||
|
||||
2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).**
|
||||
SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{
|
||||
grid-template-columns:1fr } … }` was nested high in the file, *before* the base
|
||||
`.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have
|
||||
the same specificity, so the later base rule overrode the media query — it was **dead**,
|
||||
and the inner field-grids never collapsed to one column on a phone (fields crammed 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.
|
||||
|
||||
## 48. Service Booking wizard — dynamic fields (live client search + address autocomplete), v19.0.9.7.0
|
||||
|
||||
The wizard is a **client action** (registered OWL component), not a form view, so two
|
||||
"type-ahead" features had to be built into the component itself:
|
||||
|
||||
1. **Live client search** ("Existing customer" box). Endpoint
|
||||
`/fusion_claims/service_booking/search_customers` (jsonrpc, auth=user) searches
|
||||
`res.partner` and resolves a typed SO number to its partner; the JS debounces (250 ms),
|
||||
shows a `.sb-cust-results` dropdown, and `pickCustomer()` sets `state.partnerId` + fills the
|
||||
contact fields. The backend (`action_book_from_wizard`) already consumes `partner_id` for
|
||||
`cust_mode='existing'`, so picking links the existing contact.
|
||||
**GOTCHA (cost a near-miss): `res.partner` has NO `mobile` field in Odoo 19** — a domain
|
||||
leaf `('mobile','ilike',q)` raises `ValueError: Invalid field res.partner.mobile`, and
|
||||
because the controller's `except` swallows it the search silently returns nothing (looks
|
||||
exactly like "search not working"). Build the OR domain only over fields present in
|
||||
`Partner._fields` (`name`/`phone`/`email`); same for reading `p.mobile`. Verify any new
|
||||
partner-field reference against `_fields` before shipping.
|
||||
|
||||
2. **Address autocomplete.** `google_address_autocomplete.js` patches `FormController` only,
|
||||
so it does **not** reach this client action. The component loads Google Places itself
|
||||
(key from ICP `fusion_claims.google_maps_api_key`), attaches via `useRef('root')` +
|
||||
`onMounted`/`onPatched` to every `input.sb-addr-input`, and writes
|
||||
`street`/`city`/`lat`/`lng` straight into reactive `state` (no DOM hacks — we're inside the
|
||||
component). Re-attach on patch is guarded by an `_sbAc` flag per input and an `_addrStarted`
|
||||
/`_addrNoKey` gate so a missing key just degrades to manual entry and can never break render
|
||||
(both lifecycle calls are `.catch(()=>{})`).
|
||||
|
||||
**Verifying the search without a browser:** the endpoint logic is plain ORM — smoke it in an
|
||||
`odoo shell` against real data (`Partner.search(<built domain>, limit=8)`); a vanilla repro of
|
||||
the `.sb-cust-results` dropdown markup + compiled CSS confirms the UI. Address autocomplete
|
||||
needs the live key + Google Maps, so it can only be confirmed in a real browser session.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.9.4.0',
|
||||
'version': '19.0.9.7.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
|
||||
@@ -30,6 +30,44 @@ class ServiceBookingController(http.Controller):
|
||||
'lift': labour('labour_lift')},
|
||||
}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/search_customers', type='jsonrpc', auth='user')
|
||||
def search_customers(self, query=None, **kw):
|
||||
"""Live customer lookup for the booking wizard's 'Existing customer' box.
|
||||
Matches res.partner by name / phone / mobile / email, and also resolves a
|
||||
typed sale-order reference to its customer. Returns up to 8 light dicts."""
|
||||
q = (query or '').strip()
|
||||
if len(q) < 2:
|
||||
return {'results': []}
|
||||
env = request.env
|
||||
Partner = env['res.partner'].sudo()
|
||||
# Build the OR domain only over fields that actually exist on this DB —
|
||||
# res.partner.mobile is NOT present in Odoo 19, so referencing it raises
|
||||
# ValueError and the whole lookup silently returns nothing.
|
||||
has_mobile = 'mobile' in Partner._fields
|
||||
search_fields = [f for f in ('name', 'phone', 'email', 'mobile')
|
||||
if f in Partner._fields]
|
||||
leaves = [(f, 'ilike', q) for f in search_fields]
|
||||
domain = leaves[:1]
|
||||
for leaf in leaves[1:]:
|
||||
domain = ['|'] + domain + [leaf]
|
||||
partners = Partner.search(domain, limit=8, order='write_date desc')
|
||||
# also resolve an SO number -> its partner (the hint promises "name or SO")
|
||||
if len(q) >= 3 and len(partners) < 8 and 'sale.order' in env:
|
||||
sos = env['sale.order'].sudo().search([('name', 'ilike', q)], limit=5)
|
||||
partners = (partners | sos.mapped('partner_id'))[:8]
|
||||
results = []
|
||||
for p in partners:
|
||||
phone = p.phone or (p.mobile if has_mobile else '') or ''
|
||||
results.append({
|
||||
'id': p.id,
|
||||
'name': p.name or '',
|
||||
'phone': phone,
|
||||
'email': p.email or '',
|
||||
'street': p.street or '',
|
||||
'city': p.city or '',
|
||||
})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||
def submit(self, payload=None, **kw):
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { Component, useState, onWillStart, onMounted, onPatched, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
@@ -11,10 +11,12 @@ export class ServiceBookingWizard extends Component {
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.rootRef = useRef("root");
|
||||
this.state = useState({
|
||||
custMode: "existing",
|
||||
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "" },
|
||||
partnerId: false, soSearch: "",
|
||||
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "", lat: 0, lng: 0 },
|
||||
partnerId: false, soSearch: "", custResults: [], custSearching: false,
|
||||
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
||||
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
||||
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
||||
@@ -31,6 +33,104 @@ export class ServiceBookingWizard extends Component {
|
||||
labour: r.labour || this.state.labour,
|
||||
});
|
||||
});
|
||||
// Address autocomplete attaches after the DOM exists and re-attaches when
|
||||
// OWL re-renders (e.g. switching to "New client" reveals a second address
|
||||
// input). Fully guarded — a missing Maps key just means manual entry.
|
||||
onMounted(() => { this._initAddrAutocomplete().catch(() => {}); });
|
||||
onPatched(() => { this._initAddrAutocomplete().catch(() => {}); });
|
||||
}
|
||||
|
||||
// ---- live customer search (Existing customer box) ----
|
||||
onCustSearch(ev) {
|
||||
const q = ev.target.value || "";
|
||||
this.state.soSearch = q;
|
||||
this.state.partnerId = false; // typing again unlinks any picked contact
|
||||
clearTimeout(this._custTimer);
|
||||
const term = q.trim();
|
||||
if (term.length < 2) { this.state.custResults = []; this.state.custSearching = false; return; }
|
||||
this.state.custSearching = true;
|
||||
this._custTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await rpc("/fusion_claims/service_booking/search_customers", { query: term });
|
||||
this.state.custResults = r.results || [];
|
||||
} catch (e) {
|
||||
this.state.custResults = [];
|
||||
}
|
||||
this.state.custSearching = false;
|
||||
}, 250);
|
||||
}
|
||||
pickCustomer(c) {
|
||||
this.state.partnerId = c.id;
|
||||
this.state.customer.name = c.name || "";
|
||||
this.state.customer.phone = c.phone || "";
|
||||
this.state.customer.email = c.email || "";
|
||||
this.state.customer.street = c.street || "";
|
||||
this.state.customer.city = c.city || "";
|
||||
this.state.custResults = [];
|
||||
this.state.soSearch = c.name + (c.phone ? ` · ${c.phone}` : "");
|
||||
}
|
||||
|
||||
// ---- Google Places address autocomplete (wizard-local; the FormController
|
||||
// patch in google_address_autocomplete.js does NOT reach a client action) ----
|
||||
async _getMapsKey() {
|
||||
try {
|
||||
return await this.orm.call("ir.config_parameter", "get_param", ["fusion_claims.google_maps_api_key"]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
_loadMaps(key) {
|
||||
if (window.google?.maps?.places) return Promise.resolve();
|
||||
if (window._sbMapsLoading) return window._sbMapsLoading;
|
||||
window._sbMapsLoading = new Promise((resolve, reject) => {
|
||||
window._sbMapsReady = () => resolve();
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=places&callback=_sbMapsReady`;
|
||||
s.async = true; s.defer = true; s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return window._sbMapsLoading;
|
||||
}
|
||||
async _initAddrAutocomplete() {
|
||||
const root = this.rootRef.el;
|
||||
if (!root) return;
|
||||
if (!this._addrStarted) {
|
||||
if (!root.querySelector("input.sb-addr-input")) return; // nothing to bind yet
|
||||
this._addrStarted = true;
|
||||
const key = await this._getMapsKey();
|
||||
if (!key) { this._addrNoKey = true; return; }
|
||||
try { await this._loadMaps(key); } catch (e) { return; }
|
||||
}
|
||||
if (this._addrNoKey || !window.google?.maps?.places) return;
|
||||
// re-query after the await: OWL may have swapped inputs during loading
|
||||
root.querySelectorAll("input.sb-addr-input").forEach((inp) => {
|
||||
if (inp._sbAc) return;
|
||||
inp._sbAc = true;
|
||||
try {
|
||||
const ac = new google.maps.places.Autocomplete(inp, {
|
||||
componentRestrictions: { country: "ca" },
|
||||
types: ["address"],
|
||||
fields: ["address_components", "formatted_address", "geometry"],
|
||||
});
|
||||
ac.addListener("place_changed", () => {
|
||||
const place = ac.getPlace();
|
||||
if (!place || !place.address_components) return;
|
||||
let city = "";
|
||||
for (const c of place.address_components) {
|
||||
if (c.types.includes("locality")) city = c.long_name;
|
||||
else if (c.types.includes("sublocality_level_1") && !city) city = c.long_name;
|
||||
}
|
||||
this.state.customer.street = place.formatted_address || inp.value;
|
||||
if (city) this.state.customer.city = city;
|
||||
if (place.geometry && place.geometry.location) {
|
||||
this.state.customer.lat = place.geometry.location.lat();
|
||||
this.state.customer.lng = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
inp._sbAc = false; // allow a later retry if construction failed
|
||||
}
|
||||
});
|
||||
}
|
||||
get callout() {
|
||||
if (this.state.inShop) return null;
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
color: var(--sb-text);
|
||||
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
|
||||
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;
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -63,18 +66,17 @@
|
||||
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
||||
|
||||
.body { padding: 20px 24px 6px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
|
||||
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.card {
|
||||
.sb-card {
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 13px;
|
||||
padding: 16px 17px;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
}
|
||||
.card.span2 { grid-column: 1 / -1; }
|
||||
.card h3 {
|
||||
.sb-card.span2 { grid-column: 1 / -1; }
|
||||
.sb-card h3 {
|
||||
margin: 0 0 13px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
@@ -85,8 +87,8 @@
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.card h3 .tag {
|
||||
.sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.sb-card h3 .tag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@@ -98,8 +100,8 @@
|
||||
}
|
||||
|
||||
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
||||
.row { margin-bottom: 12px; }
|
||||
.row:last-child { margin-bottom: 0; }
|
||||
.sb-row { margin-bottom: 12px; }
|
||||
.sb-row:last-child { margin-bottom: 0; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
||||
|
||||
@@ -109,8 +111,11 @@
|
||||
color: var(--sb-text);
|
||||
border: 1px solid var(--sb-field-border);
|
||||
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;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border .15s, box-shadow .15s;
|
||||
@@ -125,6 +130,28 @@
|
||||
.with-icon { position: relative; }
|
||||
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
|
||||
|
||||
// live customer-search results dropdown
|
||||
.sb-cust-search { position: relative; }
|
||||
.sb-cust-results {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 100%;
|
||||
margin-top: 4px;
|
||||
z-index: 30;
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, .16);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sb-cust-loading { padding: 10px 12px; font-size: 12.5px; color: var(--sb-faint); }
|
||||
.sb-cust-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--sb-border); }
|
||||
.sb-cust-item:last-child { border-bottom: none; }
|
||||
.sb-cust-item:hover { background: var(--sb-chip); }
|
||||
.sb-cust-name { font-size: 13.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.sb-cust-meta { font-size: 11.5px; color: var(--sb-faint); margin-top: 1px; }
|
||||
.sb-cust-linked { color: var(--sb-ok); font-weight: 600; }
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--sb-chip);
|
||||
@@ -262,7 +289,7 @@
|
||||
}
|
||||
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
||||
|
||||
.btn {
|
||||
.sb-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 18px;
|
||||
@@ -271,13 +298,32 @@
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.btn.primary {
|
||||
.sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.sb-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
||||
}
|
||||
.btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
.sb-btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
.hide { display: none !important; }
|
||||
|
||||
// Responsive overrides — MUST come AFTER the base layout rules above. These
|
||||
// selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity
|
||||
// as their base rules, so the cascade only lets the media query win when it is
|
||||
// emitted later in the source. Previously this block sat right after .sb-grid
|
||||
// (BEFORE the base .two/.three/.timepick rules), so the later base rules
|
||||
// overrode it and the inner field-grids never collapsed to one column on a
|
||||
// phone — fields crammed side-by-side. Keep these last.
|
||||
@media (max-width: 780px) {
|
||||
.sb-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.wrap { margin: 12px auto; padding: 0 10px; }
|
||||
.body { padding: 14px 16px 4px; }
|
||||
.topbar { padding: 14px 16px; }
|
||||
.foot { padding: 14px 16px; flex-wrap: wrap; }
|
||||
.two, .three { grid-template-columns: 1fr; }
|
||||
.timepick { flex-wrap: wrap; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
|
||||
<div class="o_service_booking">
|
||||
<div class="o_service_booking" t-ref="root">
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
@@ -20,11 +20,11 @@
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="grid">
|
||||
<div class="sb-grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="card">
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="row">
|
||||
<div class="sb-row">
|
||||
<div class="seg full">
|
||||
<button t-att-class="{ on: state.custMode === 'existing' }"
|
||||
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
||||
@@ -33,22 +33,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'existing'">
|
||||
<div class="row">
|
||||
<div class="sb-row sb-cust-search">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
|
||||
<div class="hint">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
<input class="f" t-att-value="state.soSearch" t-on-input="onCustSearch"
|
||||
placeholder="e.g. (416) 555-0142 …" autocomplete="off"/>
|
||||
<div class="hint" t-if="!state.partnerId">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
<div class="hint sb-cust-linked" t-if="state.partnerId">✓ Linked to existing contact — booking will use it.</div>
|
||||
<div class="sb-cust-results" t-if="state.custSearching or state.custResults.length">
|
||||
<div class="sb-cust-loading" t-if="state.custSearching">Searching…</div>
|
||||
<t t-else="">
|
||||
<div class="sb-cust-item" t-foreach="state.custResults" t-as="c" t-key="c.id"
|
||||
t-on-click="() => this.pickCustomer(c)">
|
||||
<div class="sb-cust-name"><t t-esc="c.name"/></div>
|
||||
<div class="sb-cust-meta"><t t-esc="c.phone"/><t t-if="c.phone and c.city"> · </t><t t-esc="c.city"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'new'">
|
||||
<div class="row two">
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
||||
</div>
|
||||
<div class="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||
<div class="row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||
<div class="sb-row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||
<div class="sb-row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<div class="sb-row three">
|
||||
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
||||
@@ -58,9 +70,9 @@
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="card">
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="row two">
|
||||
<div class="sb-row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" t-on-change="onDevice">
|
||||
@@ -79,7 +91,7 @@
|
||||
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" t-if="!state.inShop">
|
||||
<div class="sb-row" t-if="!state.inShop">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f"
|
||||
t-on-change="onCallType">
|
||||
@@ -100,9 +112,9 @@
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="card">
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="row two">
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" t-model.number="state.durationHr">
|
||||
@@ -114,7 +126,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="sb-row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" t-model.number="state.hour">
|
||||
@@ -140,7 +152,7 @@
|
||||
</div>
|
||||
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="sb-row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f" t-model.number="state.technicianId">
|
||||
<option value="">— Choose —</option>
|
||||
@@ -152,17 +164,17 @@
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="card">
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
||||
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
||||
</div>
|
||||
<div t-if="!state.inShop">
|
||||
<div class="row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||
<div class="sb-row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row two">
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
</div>
|
||||
@@ -170,11 +182,11 @@
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="card span2">
|
||||
<div class="sb-card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
<div class="sb-row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="sb-row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
||||
@@ -197,8 +209,8 @@
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
||||
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||
<button class="sb-btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||
<button class="sb-btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.9.4.0',
|
||||
'version': '19.0.10.3.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'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.',
|
||||
help="Customer's internal job / traveler reference.",
|
||||
)
|
||||
contact_partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer Contact',
|
||||
contact_partner_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
relation='fp_certificate_contact_partner_rel',
|
||||
column1='cert_id', column2='partner_id',
|
||||
string='Customer Contact',
|
||||
domain="[('parent_id', '=', partner_id)]",
|
||||
help="Specific contact person at the customer for this certificate. "
|
||||
'Their name, email, and phone are printed on the CoC.',
|
||||
help="Contacts at the customer who receive this certificate. "
|
||||
"Auto-filled from the customer's Default CoC Contacts when a "
|
||||
'job ships. The first is the primary (its name, email and '
|
||||
'phone print on the CoC); ALL of them are emailed when the '
|
||||
'cert is sent to the customer. (Renamed from the single '
|
||||
'contact_partner_id — see migration 19.0.10.3.0.)',
|
||||
)
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||
@@ -519,12 +526,15 @@ class FpCertificate(models.Model):
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
if (not rec.contact_partner_ids
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||
# Auto-fill ALL the customer's CoC contacts. The first is
|
||||
# the primary (printed on the CoC); every contact is emailed
|
||||
# when the cert is sent (action_send_to_customer).
|
||||
rec.contact_partner_ids = (
|
||||
rec.partner_id.x_fc_default_coc_contact_ids
|
||||
)
|
||||
# Lazy-fill the signer from the LIVE company owner (Settings
|
||||
# "Certificate Owner") when no per-cert / per-spec signer was
|
||||
@@ -578,27 +588,27 @@ class FpCertificate(models.Model):
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||
# of partner.x_fc_default_coc_contact_ids when set.
|
||||
if not rec.contact_partner_ids:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'Contact is not set.\n\nPick the recipient contact(s), '
|
||||
'or configure Default CoC Contacts on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
'Cannot issue certificate "%(name)s" — primary contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||
'contact before issuing (the cert is sent by email '
|
||||
'post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
'c': rec.contact_partner_ids[:1].name,
|
||||
})
|
||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||
# are manual-attach only — operator uploads supplier doc /
|
||||
@@ -1114,11 +1124,18 @@ class FpCertificate(models.Model):
|
||||
"""Open email composer with the certificate PDF attached."""
|
||||
self.ensure_one()
|
||||
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
||||
# Send to ALL the Customer Contacts defined on this cert (auto-filled
|
||||
# from the customer's Default CoC Contacts at job creation), falling
|
||||
# back to the company. The CoC goes to exactly the contacts shown on
|
||||
# the cert's Customer Contact field.
|
||||
recipients = self.contact_partner_ids
|
||||
partner_ids = recipients.ids or (
|
||||
[self.partner_id.id] if self.partner_id else [])
|
||||
ctx = {
|
||||
'default_model': 'fp.certificate',
|
||||
'default_res_ids': self.ids,
|
||||
'default_composition_mode': 'comment',
|
||||
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
|
||||
'default_partner_ids': partner_ids,
|
||||
}
|
||||
if self.attachment_id:
|
||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||
|
||||
@@ -128,17 +128,24 @@ class ResPartner(models.Model):
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
# ---- Default CoC contacts (cert addressees + email recipients) -------
|
||||
# One or more named contacts who receive this customer's CoC. The first
|
||||
# is the primary (printed on the cert + pre-fills cert.contact_partner_id
|
||||
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
|
||||
# sets the list once per customer. Falls back to manual selection at
|
||||
# action_issue time if blank. Self-referential M2m (renamed from the old
|
||||
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
|
||||
x_fc_default_coc_contact_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
relation='fp_default_coc_contact_rel',
|
||||
column1='partner_id', column2='contact_id',
|
||||
string='Default CoC Contacts',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
help='Contacts the Certificate of Conformance is addressed to and '
|
||||
'emailed to. The first contact is the primary (printed on the '
|
||||
'cert and pre-filled as the cert contact when a job ships); '
|
||||
'the rest are copied (CC) when the CoC is sent. Leave blank to '
|
||||
'force the manager to pick at issue time. Child contacts of '
|
||||
'this company only.',
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
'contact_partner_ids': [(6, 0, [self.contact_with_email.id])],
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
@@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase):
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
cert = self._make_cert(contact_partner_ids=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
cert = self._make_cert(contact_partner_ids=[(6, 0, [self.contact_no_email.id])])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
@@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_id=False,
|
||||
contact_partner_ids=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
<group>
|
||||
<field name="certificate_type"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="contact_partner_id"
|
||||
<field name="contact_partner_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
invisible="not partner_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
|
||||
@@ -44,15 +44,17 @@
|
||||
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<separator string="Default CoC Contacts"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
The contacts this customer's CoC is addressed to and
|
||||
emailed to. The first is the primary (printed on the
|
||||
cert); the rest are copied (CC) when the CoC is sent.
|
||||
Pre-fills cert records when a job ships. Leave blank
|
||||
to force the manager to pick at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
<field name="x_fc_default_coc_contact_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
|
||||
@@ -2678,7 +2678,7 @@ class FpJob(models.Model):
|
||||
(a per-spec override). Left empty
|
||||
otherwise so the LIVE company owner
|
||||
resolves at render / issue time.
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
@@ -2713,8 +2713,11 @@ class FpJob(models.Model):
|
||||
signer = spec.signer_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||
# ALL the customer's CoC contacts -> the cert's Customer Contact
|
||||
# M2m. First is the primary (printed on the CoC); every contact
|
||||
# is emailed at send (fp.certificate.action_send_to_customer).
|
||||
contact = self.partner_id.x_fc_default_coc_contact_ids
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
@@ -2784,8 +2787,8 @@ class FpJob(models.Model):
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'contact_partner_ids' in Cert._fields and contact:
|
||||
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
|
||||
@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
'x_fc_default_coc_contact_ids': [(6, 0, [cls.contact.id])],
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
self.assertEqual(cert.contact_partner_ids, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
'certificate_type': 'nadcap_cert',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'contact_partner_id': self.partner.id,
|
||||
'contact_partner_ids': [(6, 0, [self.partner.id])],
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'TEST PROCESS',
|
||||
'certified_by_id': self.env.user.id,
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td style="width: 50%; vertical-align: top;">
|
||||
<t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/>
|
||||
<t t-set="contact" t-value="doc.contact_partner_ids[:1] or doc.partner_id"/>
|
||||
<div>
|
||||
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
|
||||
<t t-esc="contact.name or ''"/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fusion Shipping",
|
||||
"version": "19.0.1.5.0",
|
||||
"version": "19.0.1.7.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
||||
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
||||
|
||||
@@ -431,9 +431,11 @@ class UPSRequest:
|
||||
(ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or
|
||||
(ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR')
|
||||
):
|
||||
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = {
|
||||
'Value': shipment_info.get('reference_number')
|
||||
}
|
||||
# reference_number is already a list of {'Code', 'Value'} dicts
|
||||
# (see _ups_rest_prepare_shipping_data). UPS expects ReferenceNumber
|
||||
# to be such an object (or an array of them) with a string Value --
|
||||
# NOT {'Value': [<dict>, ...]}, which UPS rejects on the ship call.
|
||||
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = shipment_info.get('reference_number')
|
||||
|
||||
# Shipments from US to CA or PR require extra info
|
||||
if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']:
|
||||
|
||||
@@ -242,9 +242,12 @@ class DeliveryCarrier(models.Model):
|
||||
('EPL', 'EPL'),
|
||||
('SPL', 'SPL')],
|
||||
string="UPS Label File Type", default='GIF')
|
||||
ups_bill_my_account = fields.Boolean(string='Bill My Account',
|
||||
help="If checked, ecommerce users will be prompted their UPS account number\n"
|
||||
"and delivery fees will be charged on it.")
|
||||
ups_bill_my_account = fields.Boolean(string="Bill recipient's UPS account",
|
||||
help="When the customer has a UPS account number on file (set on the "
|
||||
"customer's contact, Sales & Purchase tab), charge UPS shipping to "
|
||||
"THEIR account instead of yours (UPS 'Bill Receiver'). The shipping "
|
||||
"line on the order is set to $0 since the customer pays. Customers "
|
||||
"with no account on file are billed to your account at the normal rate.")
|
||||
ups_saturday_delivery = fields.Boolean(string='UPS Saturday Delivery',
|
||||
help='This value added service will allow you to ship the package on saturday also.')
|
||||
ups_cod_funds_code = fields.Selection(selection=[
|
||||
@@ -261,6 +264,16 @@ class DeliveryCarrier(models.Model):
|
||||
ups_access_token = fields.Char(string='UPS Access Token', groups="base.group_system")
|
||||
ups_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type')
|
||||
ups_require_signature = fields.Boolean("Require Signature")
|
||||
ups_rest_documentation_type = fields.Selection(
|
||||
[('none', 'No'),
|
||||
('invoice', 'UPS commercial invoice (paperless if account enrolled)')],
|
||||
string='Generate customs invoice', default='invoice',
|
||||
help="For international shipments, have UPS generate the commercial invoice "
|
||||
"from the order data (UPS 'International Forms') and attach the PDF to the "
|
||||
"delivery. If your UPS account is enrolled in Paperless Invoice, UPS also "
|
||||
"submits it electronically to customs/the recipient (and you avoid UPS's "
|
||||
"paper-invoice surcharge). Requires an HS code and country of origin on the "
|
||||
"shipped products. No invoice is generated for domestic shipments.")
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# FEDEX SOAP (Legacy) FIELDS
|
||||
@@ -1892,7 +1905,7 @@ class DeliveryCarrier(models.Model):
|
||||
currency_code = picking.sale_id.currency_id.name
|
||||
|
||||
shipment_info = {
|
||||
'require_invoice': picking._should_generate_commercial_invoice(),
|
||||
'require_invoice': (self.ups_rest_documentation_type != 'none') and picking._should_generate_commercial_invoice(),
|
||||
'invoice_date': fields.Date.today().strftime('%Y%m%d'),
|
||||
'description': picking.origin or picking.name,
|
||||
'total_qty': sum(sml.quantity for sml in picking.move_line_ids),
|
||||
|
||||
@@ -7,5 +7,8 @@ class ResPartner(models.Model):
|
||||
property_ups_carrier_account = fields.Char(
|
||||
string="UPS Account Number",
|
||||
company_dependent=True,
|
||||
help="UPS carrier account number for bill-my-account shipping.",
|
||||
help="The customer's own UPS account number. Stored on the customer and "
|
||||
"recalled automatically when you ship to them with a UPS carrier that "
|
||||
"has \"Bill recipient's UPS account\" enabled -- UPS then bills this "
|
||||
"account for the shipping (UPS 'Bill Receiver') instead of yours.",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from odoo import models, fields
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
@@ -8,6 +8,20 @@ class StockPicking(models.Model):
|
||||
string='Shipments',
|
||||
compute='_compute_fusion_shipment_count',
|
||||
)
|
||||
fusion_shipping_label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Shipping Label',
|
||||
compute='_compute_fusion_shipping_docs',
|
||||
help='Most recent shipping label generated for this delivery '
|
||||
'(any carrier).',
|
||||
)
|
||||
fusion_commercial_invoice_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Commercial Invoice',
|
||||
compute='_compute_fusion_shipping_docs',
|
||||
help='Most recent commercial (customs) invoice generated for this '
|
||||
'delivery (any carrier).',
|
||||
)
|
||||
|
||||
def _compute_fusion_shipment_count(self):
|
||||
Shipment = self.env['fusion.shipment']
|
||||
@@ -16,6 +30,68 @@ class StockPicking(models.Model):
|
||||
[('picking_id', '=', picking.id)]
|
||||
)
|
||||
|
||||
@api.depends('message_ids.attachment_ids')
|
||||
def _compute_fusion_shipping_docs(self):
|
||||
"""Find the latest shipping label + commercial invoice that the
|
||||
carrier posted to this delivery's chatter, so they can be opened
|
||||
from a smart button.
|
||||
|
||||
Naming is carrier-agnostic (see the carriers' send_shipping):
|
||||
- labels contain 'Label' (Label-, LabelUPS, LabelShipping-...)
|
||||
- invoices contain 'CommercialInvoice' (UPS/Canada Post) or start
|
||||
with 'ShippingDoc-' (FedEx/DHL, via _get_delivery_doc_prefix).
|
||||
"""
|
||||
Attachment = self.env['ir.attachment']
|
||||
for picking in self:
|
||||
invoice = label = Attachment
|
||||
if isinstance(picking.id, int):
|
||||
atts = Attachment.search(
|
||||
[('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', picking.id)],
|
||||
order='id desc')
|
||||
for att in atts:
|
||||
name = att.name or ''
|
||||
norm = name.lower().replace(' ', '').replace('-', '')
|
||||
if 'commercialinvoice' in norm or name.startswith('ShippingDoc'):
|
||||
invoice = invoice or att
|
||||
elif 'label' in name.lower() and 'return' not in name.lower():
|
||||
label = label or att
|
||||
if invoice and label:
|
||||
break
|
||||
picking.fusion_commercial_invoice_attachment_id = invoice.id
|
||||
picking.fusion_shipping_label_attachment_id = label.id
|
||||
|
||||
def _fusion_open_attachment(self, attachment):
|
||||
"""Open a shipping document for the operator.
|
||||
|
||||
Routes PDFs through ir.attachment.action_fusion_preview (preview
|
||||
dialog) when fusion_pdf_preview is installed, else opens the file
|
||||
in a new tab. Mirrors fusion.shipment._action_open_attachment.
|
||||
See CLAUDE.md "PDF Preview".
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not attachment:
|
||||
return False
|
||||
if hasattr(attachment, 'action_fusion_preview'):
|
||||
return attachment.action_fusion_preview(
|
||||
title=attachment.name or _('Shipping Document'),
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=false' % attachment.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_fusion_shipping_label(self):
|
||||
return self._fusion_open_attachment(
|
||||
self.fusion_shipping_label_attachment_id)
|
||||
|
||||
def action_view_fusion_commercial_invoice(self):
|
||||
return self._fusion_open_attachment(
|
||||
self.fusion_commercial_invoice_attachment_id)
|
||||
|
||||
def action_view_fusion_shipments(self):
|
||||
self.ensure_one()
|
||||
shipments = self.env['fusion.shipment'].search(
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
<field name="ups_require_signature"/>
|
||||
<field name="ups_duty_payment" string="Duties paid by"
|
||||
required="delivery_type == 'fusion_ups_rest'"/>
|
||||
<field name="ups_rest_documentation_type"
|
||||
required="delivery_type == 'fusion_ups_rest'"/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@@ -6,12 +6,28 @@
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<field name="fusion_shipping_label_attachment_id" invisible="1"/>
|
||||
<field name="fusion_commercial_invoice_attachment_id" invisible="1"/>
|
||||
<button name="action_view_fusion_shipments" type="object"
|
||||
class="oe_stat_button" icon="fa-plane"
|
||||
invisible="fusion_shipment_count == 0">
|
||||
<field name="fusion_shipment_count" widget="statinfo"
|
||||
string="Shipments"/>
|
||||
</button>
|
||||
<button name="action_view_fusion_shipping_label" type="object"
|
||||
class="oe_stat_button" icon="fa-print"
|
||||
invisible="not fusion_shipping_label_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Shipping Label</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_fusion_commercial_invoice" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not fusion_commercial_invoice_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Commercial Invoice</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -140,6 +140,22 @@ else
|
||||
TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user