Compare commits
20 Commits
27577dd51a
...
claude/ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d06ff77f | ||
|
|
53fe13344d | ||
|
|
423f288507 | ||
|
|
a86f20017d | ||
|
|
7426501555 | ||
|
|
3e787a1b24 | ||
|
|
6f006e24ad | ||
|
|
ba6aeaaca9 | ||
|
|
dcd4955bb7 | ||
|
|
197030a188 | ||
|
|
c97a0d985c | ||
|
|
e6bbf566ca | ||
|
|
86e9fdead8 | ||
|
|
c80ffa1b2c | ||
|
|
97880765b5 | ||
|
|
587988bb06 | ||
|
|
a209648ed9 | ||
|
|
ea6b3fe2e9 | ||
|
|
b23eaa5695 | ||
|
|
489312365e |
@@ -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.5.0',
|
||||
'version': '19.0.9.7.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -66,26 +66,17 @@
|
||||
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
||||
|
||||
.body { padding: 20px 24px 6px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 560px) {
|
||||
.wrap { margin: 12px auto; padding: 0 10px; }
|
||||
.body { padding: 14px 16px 4px; }
|
||||
.topbar { padding: 14px 16px; }
|
||||
.foot { padding: 14px 16px; flex-wrap: wrap; }
|
||||
.two, .three { grid-template-columns: 1fr; }
|
||||
.timepick { flex-wrap: wrap; }
|
||||
}
|
||||
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.card {
|
||||
.sb-card {
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 13px;
|
||||
padding: 16px 17px;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
}
|
||||
.card.span2 { grid-column: 1 / -1; }
|
||||
.card h3 {
|
||||
.sb-card.span2 { grid-column: 1 / -1; }
|
||||
.sb-card h3 {
|
||||
margin: 0 0 13px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
@@ -96,8 +87,8 @@
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.card h3 .tag {
|
||||
.sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.sb-card h3 .tag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@@ -109,8 +100,8 @@
|
||||
}
|
||||
|
||||
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
||||
.row { margin-bottom: 12px; }
|
||||
.row:last-child { margin-bottom: 0; }
|
||||
.sb-row { margin-bottom: 12px; }
|
||||
.sb-row:last-child { margin-bottom: 0; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
||||
|
||||
@@ -139,6 +130,28 @@
|
||||
.with-icon { position: relative; }
|
||||
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
|
||||
|
||||
// live customer-search results dropdown
|
||||
.sb-cust-search { position: relative; }
|
||||
.sb-cust-results {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 100%;
|
||||
margin-top: 4px;
|
||||
z-index: 30;
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, .16);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sb-cust-loading { padding: 10px 12px; font-size: 12.5px; color: var(--sb-faint); }
|
||||
.sb-cust-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--sb-border); }
|
||||
.sb-cust-item:last-child { border-bottom: none; }
|
||||
.sb-cust-item:hover { background: var(--sb-chip); }
|
||||
.sb-cust-name { font-size: 13.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.sb-cust-meta { font-size: 11.5px; color: var(--sb-faint); margin-top: 1px; }
|
||||
.sb-cust-linked { color: var(--sb-ok); font-weight: 600; }
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--sb-chip);
|
||||
@@ -276,7 +289,7 @@
|
||||
}
|
||||
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
||||
|
||||
.btn {
|
||||
.sb-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 18px;
|
||||
@@ -285,13 +298,32 @@
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.btn.primary {
|
||||
.sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.sb-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
||||
}
|
||||
.btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
.sb-btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
.hide { display: none !important; }
|
||||
|
||||
// Responsive overrides — MUST come AFTER the base layout rules above. These
|
||||
// selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity
|
||||
// as their base rules, so the cascade only lets the media query win when it is
|
||||
// emitted later in the source. Previously this block sat right after .sb-grid
|
||||
// (BEFORE the base .two/.three/.timepick rules), so the later base rules
|
||||
// overrode it and the inner field-grids never collapsed to one column on a
|
||||
// phone — fields crammed side-by-side. Keep these last.
|
||||
@media (max-width: 780px) {
|
||||
.sb-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.wrap { margin: 12px auto; padding: 0 10px; }
|
||||
.body { padding: 14px 16px 4px; }
|
||||
.topbar { padding: 14px 16px; }
|
||||
.foot { padding: 14px 16px; flex-wrap: wrap; }
|
||||
.two, .three { grid-template-columns: 1fr; }
|
||||
.timepick { flex-wrap: wrap; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,7 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). **EXCEPTION — do NOT add `.article` to the dpi=96 mm-based job stickers** (`fusion_plating_jobs/report/report_fp_job_sticker.xml`): those set a custom `@page` + `dpi=96` so mm maps 1:1 (rule 14), and wrapping their body in `<div class="article">` re-routes through Odoo's standard report CSS which **blows up the mm/dpi layout** — the logo + every element renders huge (tested + reverted 2026-06-04). For those labels do the OPPOSITE: leave the raw `html_container` and **strip the offending non-ASCII glyph to ASCII in the Python display helper** instead (`fp_job_sticker.py::_clean()` maps `º`/`°`/`˚`→'' , em-dash→`-`, smart-quotes→ASCII). Trade-off: you lose the literal glyph (a bake temp `375°F` prints `375F`) but the label stays clean + correctly sized. So: `.article` for normal-flow custom-header reports, ASCII-strip for the fixed-dpi mm labels. | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
|
||||
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
|
||||
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.9.3.0',
|
||||
'version': '19.0.10.3.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -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,
|
||||
@@ -436,6 +443,47 @@ class FpCertificate(models.Model):
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
def _fp_needs_thickness_data(self):
|
||||
"""True when this cert MUST carry thickness data to be issued.
|
||||
|
||||
Single source of truth shared by action_issue (hard gate) and the
|
||||
Issue-Certs wizard (readiness hint) so the two can never drift.
|
||||
|
||||
Partner side — the ceiling: a CoC needs thickness when the customer
|
||||
is strict-thickness (aerospace / Nadcap) or opts into the
|
||||
thickness-on-CoC bundle; a thickness_report cert always needs it.
|
||||
|
||||
Recipe side — suppress-only: a recipe whose requires_thickness_report
|
||||
is False (passivation, chemical conversion, anodize seal-only — no
|
||||
plating thickness physically exists) REMOVES the requirement even
|
||||
when the customer asked. This mirrors Step 2 of
|
||||
fp.job._resolve_required_cert_types so the cert-type resolver and
|
||||
this issue-time gate agree. Without it, a passivation CoC for a
|
||||
thickness customer can never be issued (the gate demands Fischerscope
|
||||
data the process cannot produce). Field-existence guards keep this
|
||||
safe when fusion_plating_jobs / fusion_plating are at an older
|
||||
schema or not installed.
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner = self.partner_id
|
||||
needs = (
|
||||
self.certificate_type == 'thickness_report'
|
||||
or (self.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if not needs:
|
||||
return False
|
||||
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
|
||||
recipe = job.recipe_id if job else False
|
||||
if (recipe and 'requires_thickness_report' in recipe._fields
|
||||
and not recipe.requires_thickness_report):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
||||
@@ -478,12 +526,15 @@ class FpCertificate(models.Model):
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
if (not rec.contact_partner_ids
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||
# Auto-fill ALL the customer's CoC contacts. The first is
|
||||
# the primary (printed on the CoC); every contact is emailed
|
||||
# when the cert is sent (action_send_to_customer).
|
||||
rec.contact_partner_ids = (
|
||||
rec.partner_id.x_fc_default_coc_contact_ids
|
||||
)
|
||||
# Lazy-fill the signer from the LIVE company owner (Settings
|
||||
# "Certificate Owner") when no per-cert / per-spec signer was
|
||||
@@ -537,27 +588,27 @@ class FpCertificate(models.Model):
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||
# of partner.x_fc_default_coc_contact_ids when set.
|
||||
if not rec.contact_partner_ids:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'Contact is not set.\n\nPick the recipient contact(s), '
|
||||
'or configure Default CoC Contacts on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
'Cannot issue certificate "%(name)s" — primary contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||
'contact before issuing (the cert is sent by email '
|
||||
'post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
'c': rec.contact_partner_ids[:1].name,
|
||||
})
|
||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||
# are manual-attach only — operator uploads supplier doc /
|
||||
@@ -580,30 +631,18 @@ class FpCertificate(models.Model):
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
})
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
# Thickness data requirement — _fp_needs_thickness_data is the
|
||||
# single source of truth (shared with the Issue-Certs wizard).
|
||||
# A customer needs thickness data when the cert is a
|
||||
# thickness_report, or it's a CoC and the partner is
|
||||
# strict-thickness / opts into the thickness-on-CoC bundle —
|
||||
# UNLESS the job's recipe suppresses thickness
|
||||
# (requires_thickness_report=False: passivation, chemical
|
||||
# conversion, anodize seal-only — no plating thickness exists).
|
||||
# Acceptable data: logged readings on the cert OR a Fischerscope
|
||||
# PDF on the linked QC OR a cert-local Fischerscope upload.
|
||||
partner = rec.partner_id
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
if rec._fp_needs_thickness_data():
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
@@ -1085,11 +1124,18 @@ class FpCertificate(models.Model):
|
||||
"""Open email composer with the certificate PDF attached."""
|
||||
self.ensure_one()
|
||||
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
||||
# Send to ALL the Customer Contacts defined on this cert (auto-filled
|
||||
# from the customer's Default CoC Contacts at job creation), falling
|
||||
# back to the company. The CoC goes to exactly the contacts shown on
|
||||
# the cert's Customer Contact field.
|
||||
recipients = self.contact_partner_ids
|
||||
partner_ids = recipients.ids or (
|
||||
[self.partner_id.id] if self.partner_id else [])
|
||||
ctx = {
|
||||
'default_model': 'fp.certificate',
|
||||
'default_res_ids': self.ids,
|
||||
'default_composition_mode': 'comment',
|
||||
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
|
||||
'default_partner_ids': partner_ids,
|
||||
}
|
||||
if self.attachment_id:
|
||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||
|
||||
@@ -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+)"/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.12.1.6',
|
||||
'version': '19.0.12.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -2379,7 +2379,14 @@ class FpJob(models.Model):
|
||||
if 'contact_name' in Delivery._fields and ship_to.name:
|
||||
vals['contact_name'] = ship_to.name
|
||||
if 'contact_phone' in Delivery._fields:
|
||||
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
|
||||
# res.partner has no `mobile` field in this Odoo 19 build —
|
||||
# guard it so the read can't AttributeError (and still picks
|
||||
# up mobile on instances that do define it).
|
||||
vals['contact_phone'] = (
|
||||
ship_to.phone
|
||||
or (ship_to.mobile if 'mobile' in ship_to._fields else '')
|
||||
or ''
|
||||
)
|
||||
# Scheduled date — operator can adjust; this just primes it
|
||||
# so they're not staring at a blank field.
|
||||
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
|
||||
@@ -2671,7 +2678,7 @@ class FpJob(models.Model):
|
||||
(a per-spec override). Left empty
|
||||
otherwise so the LIVE company owner
|
||||
resolves at render / issue time.
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
@@ -2706,8 +2713,11 @@ class FpJob(models.Model):
|
||||
signer = spec.signer_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||
# ALL the customer's CoC contacts -> the cert's Customer Contact
|
||||
# M2m. First is the primary (printed on the CoC); every contact
|
||||
# is emailed at send (fp.certificate.action_send_to_customer).
|
||||
contact = self.partner_id.x_fc_default_coc_contact_ids
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
@@ -2777,8 +2787,8 @@ class FpJob(models.Model):
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'contact_partner_ids' in Cert._fields and contact:
|
||||
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
|
||||
@@ -486,6 +486,45 @@ class FpJobStep(models.Model):
|
||||
step.state = 'cancelled'
|
||||
return True
|
||||
|
||||
def button_reset(self):
|
||||
"""Reset a step back to 'ready' so it can be redone — operator
|
||||
self-serve for a mistake, an accidental skip, or a customer redo
|
||||
request. Clears the finish + sign-off stamps and closes any open
|
||||
timelog so the redo re-captures them; KEEPS the first-start audit
|
||||
(date_started / started_by) and the move / CoC history intact.
|
||||
Posts a chatter audit on the parent job. No-op on a step already
|
||||
ready/pending (nothing to undo).
|
||||
"""
|
||||
now = fields.Datetime.now()
|
||||
for step in self:
|
||||
if step.state in ('ready', 'pending'):
|
||||
continue
|
||||
prev_label = dict(
|
||||
step._fields['state'].selection
|
||||
).get(step.state, step.state)
|
||||
open_logs = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished)
|
||||
if open_logs:
|
||||
open_logs.write({'date_finished': now, 'state': 'stopped'})
|
||||
vals = {'state': 'ready'}
|
||||
if 'date_finished' in step._fields:
|
||||
vals['date_finished'] = False
|
||||
if 'finished_by_user_id' in step._fields:
|
||||
vals['finished_by_user_id'] = False
|
||||
if 'signoff_user_id' in step._fields:
|
||||
vals['signoff_user_id'] = False
|
||||
step.write(vals)
|
||||
if step.job_id:
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(s)s" reset to Ready (was %(p)s) by %(u)s '
|
||||
'for redo.'
|
||||
) % {
|
||||
's': step.name or '?',
|
||||
'p': prev_label,
|
||||
'u': self.env.user.display_name,
|
||||
})
|
||||
return True
|
||||
|
||||
def write(self, vals):
|
||||
"""Post a chatter trail on the parent JOB whenever an active
|
||||
step gets reassigned. The step itself already tracks
|
||||
@@ -846,6 +885,16 @@ class FpJobStep(models.Model):
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
if not node:
|
||||
return Prompt
|
||||
# Master switch (Sub 12d): when the recipe node opts OUT of
|
||||
# measurement collection, the Record-Inputs wizard returns ZERO
|
||||
# rows (fp_job_step_input_wizard.default_get). The finish gate MUST
|
||||
# agree — otherwise required prompts are demanded with no way to
|
||||
# enter them and the step is permanently stuck (bake nodes with
|
||||
# collect_measurements=False but required prompts — WO-30098 + 63
|
||||
# others on entech). Honour the switch here so gate <=> wizard.
|
||||
if ('collect_measurements' in node._fields
|
||||
and not node.collect_measurements):
|
||||
return Prompt
|
||||
prompts = node.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||
|
||||
@@ -18,7 +18,13 @@ def _clean(text):
|
||||
t = str(text)
|
||||
for a, b in ((u'—', '-'), (u'–', '-'), (u'‘', "'"),
|
||||
(u'’', "'"), (u'“', '"'), (u'”', '"'),
|
||||
(u'…', '...')):
|
||||
(u'…', '...'),
|
||||
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
|
||||
# type for "375ºF", the real degree '°' (U+00B0), and the ring
|
||||
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
|
||||
# html_container path (no .article UTF-8 wrapper — and adding one
|
||||
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
|
||||
(u'º', ''), (u'°', ''), (u'˚', '')):
|
||||
t = t.replace(a, b)
|
||||
return t.strip()
|
||||
|
||||
@@ -88,6 +94,9 @@ class FpJob(models.Model):
|
||||
'qty': qty,
|
||||
'due': due_s,
|
||||
'thk': thk,
|
||||
# Real thickness present (has a digit) — drives the prominent
|
||||
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
|
||||
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
|
||||
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
|
||||
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
|
||||
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# reaches state='done'.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
@@ -65,3 +66,34 @@ class FpReceiving(models.Model):
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
|
||||
# ---- Sticker printing from the Receiving screen (2026-06-04) ----------
|
||||
# Both stickers loop the SO's boxes (one label per box). Pass a SINGLE
|
||||
# work order: the box loop is sale-order-scoped, so feeding every job
|
||||
# would reprint each box label once per job. One job → exactly one label
|
||||
# per box. Falls back to a single 1/1 label when no boxes exist yet.
|
||||
def _fp_sticker_jobs(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return self.env['fp.job']
|
||||
return self.env['fp.job'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='id', limit=1)
|
||||
|
||||
def _fp_print_sticker(self, xmlid):
|
||||
self.ensure_one()
|
||||
jobs = self._fp_sticker_jobs()
|
||||
if not jobs:
|
||||
raise UserError(_(
|
||||
'No work order exists for this receiving yet — create the '
|
||||
'Work Order before printing stickers.'))
|
||||
return self.env.ref(xmlid).report_action(jobs)
|
||||
|
||||
def action_print_external_sticker(self):
|
||||
"""Customer (external) box sticker(s) for this receiving's WO."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker')
|
||||
|
||||
def action_print_internal_sticker(self):
|
||||
"""Shop (internal) box sticker(s) — same layout, internal notes."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker_internal')
|
||||
|
||||
@@ -57,8 +57,11 @@
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<!-- NOT bound to the fp.job Print menu (removed from the list per
|
||||
request). Printed via the Receiving screen button
|
||||
(fp.receiving.action_print_internal_sticker). eval=False clears
|
||||
any binding a prior install left in the DB. -->
|
||||
<field name="binding_model_id" eval="False"/>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
@@ -84,13 +87,13 @@
|
||||
/* Layout B rail + main */
|
||||
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
|
||||
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
|
||||
.r-logo { height: 11mm; line-height: 11mm; text-align: center; }
|
||||
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 14mm; background: #000; color: #fff; padding: 0; }
|
||||
.r-logo { height: 9mm; line-height: 9mm; text-align: center; }
|
||||
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
|
||||
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
|
||||
.r-qrflags { height: 36mm; text-align: center; }
|
||||
.r-qrflags { height: 32mm; text-align: center; }
|
||||
.qftbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.qftbl td { vertical-align: middle; text-align: center; }
|
||||
.qfqr { width: 66%; }
|
||||
@@ -104,72 +107,23 @@
|
||||
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
|
||||
.qftags { width: 34%; border-left: 0.5mm solid #000; }
|
||||
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
|
||||
.qffull { line-height: 36mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; }
|
||||
/* Internal (Layout A) header QR — same ~10% quiet-zone crop, bigger box. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; }
|
||||
.r-fld { padding: 1mm 2.2mm; }
|
||||
.qffull { line-height: 32mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
|
||||
/* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
|
||||
30mm -> 27mm so the QR-driven black band is shorter and the freed
|
||||
height flows to the NOTES block below. Still well above scan size. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 33.75mm; height: 33.75mm; top: -3.375mm; left: -3.375mm; }
|
||||
.r-fld { padding: 0.7mm 2.2mm; }
|
||||
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.m-thk { padding: 1.8mm 2.6mm 2.4mm; }
|
||||
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal body — Layout A ===================== -->
|
||||
<template id="fp_job_internal_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<table class="fpt">
|
||||
<tr style="height:22mm" class="rule">
|
||||
<td class="band pad">
|
||||
<span style="float:right"><span class="tag">INTERNAL</span></span>
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
|
||||
</td>
|
||||
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;vertical-align:middle;padding:1mm">
|
||||
<span class="qfwrap-int"><img t-att-src="_qr"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:12mm" class="rule">
|
||||
<td class="pad" colspan="2">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
<span style="float:right">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:10mm" class="rule">
|
||||
<td style="padding:0" colspan="2"><table class="fpt"><tr>
|
||||
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
|
||||
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
|
||||
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></td>
|
||||
</tr>
|
||||
<t t-if="d['bake']">
|
||||
<tr style="height:13mm" class="rule">
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
|
||||
<span class="inshead">BAKE</span>
|
||||
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
|
||||
<span class="inshead">NOTES</span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External body — Layout B ===================== -->
|
||||
<template id="fp_job_external_body">
|
||||
<div class="label-page"><div class="label">
|
||||
@@ -215,17 +169,24 @@
|
||||
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
|
||||
</tr></table></div>
|
||||
<div style="height:8.5mm"><table class="gtbl"><tr>
|
||||
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
</tr></table></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<!-- Plating thickness — the team's most-watched spec. Relocated
|
||||
out of the cramped rail to a prominent full-width banner at
|
||||
the top of the main panel. -->
|
||||
<t t-if="d['has_thk']">
|
||||
<div class="m-thk rule"><span class="inshead">PLATING THICKNESS</span>
|
||||
<div style="font-size:21pt;font-weight:900;line-height:1;margin-top:1.4mm"><t t-esc="d['thk']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="d['bake']">
|
||||
<div class="m-bake rule"><span class="inshead">BAKE</span>
|
||||
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="m-notes"><span class="inshead">NOTES</span>
|
||||
<div class="m-notes"><span class="inshead"><t t-esc="_notes_label or 'NOTES'"/></span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,16 +194,35 @@
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal outer (per job) ===================== -->
|
||||
<!-- Internal sticker = a COPY of the External (Layout B, one per box) but
|
||||
showing the INTERNAL description instead of the customer-facing notes,
|
||||
and a "INTERNAL NOTES" header so the shop copy can't be confused with
|
||||
the customer copy. Identical rail/QR/logo/box otherwise. -->
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['internal_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'INTERNAL NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_internal_body"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
@@ -254,6 +234,7 @@
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['customer_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
|
||||
@@ -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,
|
||||
@@ -141,3 +141,48 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
cert.with_context(
|
||||
fp_skip_cert_authority_gate=True
|
||||
).action_issue()
|
||||
|
||||
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
|
||||
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
|
||||
"""A passivation recipe (requires_thickness_report=False) must drop
|
||||
the thickness-data requirement at issue time too — even for a
|
||||
strict-thickness customer. Regression: the recipe-suppression
|
||||
feature updated _resolve_required_cert_types but NOT the
|
||||
action_issue / wizard thickness gate, so passivation CoCs could
|
||||
never be issued (gate demanded Fischerscope data the process
|
||||
cannot produce)."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.partner.x_fc_strict_thickness_required = True
|
||||
self.recipe.requires_thickness_report = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-PASSIVATION',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
# Recipe suppresses thickness -> the shared gate must NOT demand it.
|
||||
self.assertFalse(cert._fp_needs_thickness_data())
|
||||
|
||||
# ---- Test 8: normal recipe still enforces thickness (control) ----
|
||||
def test_normal_recipe_keeps_thickness_issue_gate(self):
|
||||
"""Control for Test 7: a recipe that allows thickness
|
||||
(requires_thickness_report default True) still demands thickness
|
||||
data on the CoC for a thickness customer. Guards against
|
||||
over-suppression weakening real aerospace enforcement."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
# self.recipe.requires_thickness_report stays default True.
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-NORMAL',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
self.assertTrue(cert._fp_needs_thickness_data())
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="part_catalog_id" string="Part"/>
|
||||
<field name="customer_spec_id" string="Specification"/>
|
||||
<field name="recipe_id" string="Process Recipe"/>
|
||||
<field name="customer_spec_id" string="Specification" invisible="1"/>
|
||||
<field name="recipe_id" string="Process Recipe" readonly="1"/>
|
||||
</xpath>
|
||||
<!-- Show qty completed alongside total so the partial-qty
|
||||
picture is visible at a glance without opening Move Log. -->
|
||||
@@ -118,7 +118,7 @@
|
||||
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
|
||||
<field name="step_ids" mode="list"
|
||||
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}">
|
||||
<list editable="bottom"
|
||||
<list editable="bottom" create="false" delete="false"
|
||||
decoration-info="state in ('ready', 'in_progress')"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-warning="state == 'paused'"
|
||||
@@ -162,6 +162,14 @@
|
||||
title="Finish & Next" icon="fa-check-circle"
|
||||
class="btn-link o_fp_finish_btn"
|
||||
invisible="state != 'in_progress'"/>
|
||||
<!-- Reset / redo — back to Ready so the step can be
|
||||
run again (mistake, accidental skip, customer
|
||||
redo). Clears finish + sign-off stamps; keeps the
|
||||
start audit + moves. Hidden on ready/pending. -->
|
||||
<button name="button_reset" type="object"
|
||||
title="Reset step (redo)" icon="fa-undo"
|
||||
class="btn-link text-warning"
|
||||
invisible="state in ('ready', 'pending')"/>
|
||||
|
||||
<!-- Secondary actions — small icons only. Pause is
|
||||
only relevant on a running step; Record Inputs
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||
<!-- Print box stickers for this receiving's work order — one
|
||||
label per tracked box (external = customer copy, internal
|
||||
= shop copy with internal notes). Shown once a WO exists. -->
|
||||
<button name="action_print_external_sticker"
|
||||
string="External Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the customer (external) box sticker(s) — one per box."/>
|
||||
<button name="action_print_internal_sticker"
|
||||
string="Internal Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the shop (internal) box sticker(s) — same layout, internal notes."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order smart button on the button_box (mirrors the
|
||||
|
||||
@@ -461,17 +461,18 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required',
|
||||
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
|
||||
def _compute_needs_thickness(self):
|
||||
# Delegate to fp.certificate._fp_needs_thickness_data — the single
|
||||
# source of truth shared with the action_issue hard gate — so the
|
||||
# wizard's readiness hint and the gate can never drift. Honours
|
||||
# recipe-level thickness suppression (passivation = no thickness
|
||||
# even if the customer asked).
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
ln.cert_id._fp_needs_thickness_data()
|
||||
if ln.cert_id else False
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Scheduled delivery of finished parts back to a customer.
|
||||
@@ -480,6 +485,51 @@ class FpDelivery(models.Model):
|
||||
or rec.vehicle_id.display_name
|
||||
or 'Driver'),
|
||||
)
|
||||
# Packing slip travels with the shipment — render + attach it on
|
||||
# dispatch so the driver/customer get it and it's on the record.
|
||||
self._fp_generate_packing_slip()
|
||||
|
||||
def _fp_generate_packing_slip(self, force=False):
|
||||
"""Render each delivery's packing slip and store it on
|
||||
packing_list_attachment_id so it ships with the goods.
|
||||
|
||||
Fired on dispatch (action_start_route) and as a catch-all on
|
||||
action_mark_delivered. Idempotent + best-effort: skips deliveries
|
||||
that already carry a slip (unless force=True) and never raises —
|
||||
a report glitch must not block shipping. The report action is
|
||||
resolved at runtime so this module needs no hard dependency on
|
||||
fusion_plating_reports.
|
||||
"""
|
||||
report_xmlid = (
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait'
|
||||
)
|
||||
report = self.env.ref(report_xmlid, raise_if_not_found=False)
|
||||
if not report:
|
||||
return
|
||||
for rec in self:
|
||||
if 'packing_list_attachment_id' not in rec._fields:
|
||||
return
|
||||
if rec.packing_list_attachment_id and not force:
|
||||
continue
|
||||
try:
|
||||
report_model = self.env['ir.actions.report'].sudo()
|
||||
pdf_bytes, _fmt = report_model._render_qweb_pdf(
|
||||
report_xmlid, res_ids=rec.ids)
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': _('Packing Slip - %s.pdf') % rec.display_name,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(pdf_bytes),
|
||||
'mimetype': 'application/pdf',
|
||||
'res_model': rec._name,
|
||||
'res_id': rec.id,
|
||||
})
|
||||
rec.packing_list_attachment_id = att.id
|
||||
rec.message_post(
|
||||
body=_('Packing slip generated and attached.'))
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Packing slip render failed for delivery %s: %s',
|
||||
rec.display_name, exc)
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Block "delivered" until a Proof of Delivery exists.
|
||||
@@ -511,6 +561,9 @@ class FpDelivery(models.Model):
|
||||
# Sub 8 — box-parity warning. Non-blocking; just posts to
|
||||
# chatter so the shipping supervisor sees it on the record.
|
||||
rec._fp_check_box_parity()
|
||||
# Catch-all: ensure a slip exists even if dispatch was skipped
|
||||
# (generate-if-missing — won't overwrite the dispatch-time one).
|
||||
self._fp_generate_packing_slip()
|
||||
|
||||
def _fp_check_box_parity(self):
|
||||
"""Compare this delivery's boxes-out count to the boxes-in count
|
||||
|
||||
@@ -377,7 +377,7 @@ class FpNotificationTemplate(models.Model):
|
||||
# Packing slip — gated by customer preference (default True)
|
||||
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
|
||||
att = _render_report(
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_portrait', delivery,
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait', delivery,
|
||||
)
|
||||
if att:
|
||||
ids.append(att)
|
||||
|
||||
@@ -74,6 +74,15 @@ class SaleOrder(models.Model):
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
# Seamless flow: after a single interactive confirm, jump straight
|
||||
# to the Receive Parts screen so the dock counts parts in right away
|
||||
# (idiot-proof — no hunting for the smart button). Guarded to a
|
||||
# single order + an opt-out context flag so batch / programmatic
|
||||
# confirms (and tests) keep the native return value.
|
||||
if (len(self) == 1
|
||||
and not self.env.context.get('fp_no_receiving_redirect')
|
||||
and self.x_fc_receiving_ids):
|
||||
return self.action_view_receiving()
|
||||
return res
|
||||
|
||||
def action_view_receiving(self):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.34.0',
|
||||
'version': '19.0.11.36.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
@@ -25,6 +25,11 @@
|
||||
# creates a cycle. Our only fp.job touchpoint is wo_scan.py which
|
||||
# uses runtime env.get('fp.job') — safe without the manifest dep.
|
||||
'fusion_plating_logistics',
|
||||
# Needed for the packing-slip Print binding on fp.receiving
|
||||
# (binding_model_id ref). Already a transitive dep via logistics;
|
||||
# declared explicitly so the ref is robust. No cycle — receiving
|
||||
# does not depend on reports.
|
||||
'fusion_plating_receiving',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
@@ -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 ''"/>
|
||||
|
||||
@@ -461,4 +461,271 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- LOCAL DELIVERY variant (fusion.plating.delivery) -->
|
||||
<!-- The stock.picking templates above don't fit shops that ship -->
|
||||
<!-- via fusion.plating.delivery (no picking). This variant -->
|
||||
<!-- resolves the SO + lines from the delivery's job_ref (same -->
|
||||
<!-- pattern as the BoL report) and reuses the shared styles / -->
|
||||
<!-- address / signoff bits. Items come from the SO lines (vs -->
|
||||
<!-- stock moves). -->
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<!-- Items table sourced from sale.order.line (not stock.move) -->
|
||||
<template id="fp_packing_slip_items_lines">
|
||||
<table class="bordered fp-ps-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th t-att-style="w_ordered or 'width: 8%;'">
|
||||
Ordered<span class="fp-fr">Comm.</span>
|
||||
</th>
|
||||
<th t-att-style="w_shipped or 'width: 8%;'">
|
||||
Shipped<span class="fp-fr">EXP</span>
|
||||
</th>
|
||||
<th t-att-style="w_bo or 'width: 8%;'">
|
||||
B/O<span class="fp-fr">À venir</span>
|
||||
</th>
|
||||
<th class="text-start" t-att-style="w_part or 'width: 17%;'">
|
||||
Part Number<span class="fp-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th t-att-style="w_po or 'width: 11%;'">
|
||||
PO<span class="fp-fr">B/C</span>
|
||||
</th>
|
||||
<th t-att-style="w_wo or 'width: 11%;'">
|
||||
WO<span class="fp-fr">B/T</span>
|
||||
</th>
|
||||
<th t-att-style="w_process or 'width: 14%;'">
|
||||
Process<span class="fp-fr">Procédé</span>
|
||||
</th>
|
||||
<th class="text-start" t-att-style="w_desc or 'width: 23%;'">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="lines" t-as="line">
|
||||
<t t-set="ordered_qty" t-value="line.product_uom_qty or 0.0"/>
|
||||
<t t-set="wo_job" t-value="line.env['fp.job'].sudo().search([('sale_order_line_ids', 'in', line.ids)], limit=1)"/>
|
||||
<t t-set="done_qty" t-value="(wo_job.qty_done if wo_job and wo_job.qty_done else ordered_qty)"/>
|
||||
<t t-set="bo_qty" t-value="ordered_qty - done_qty if ordered_qty > done_qty else 0.0"/>
|
||||
<t t-set="proc_variant" t-value="(line.x_fc_process_variant_id if 'x_fc_process_variant_id' in line._fields else False)"/>
|
||||
<t t-set="proc_label" t-value="(proc_variant.variant_label or proc_variant.name) if proc_variant else ((line.x_fc_part_catalog_id.default_process_id.variant_label or line.x_fc_part_catalog_id.default_process_id.name) if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id else '')"/>
|
||||
<tr>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(ordered_qty) if ordered_qty == int(ordered_qty) else ordered_qty"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(done_qty) if done_qty == int(done_qty) else done_qty"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(bo_qty) if bo_qty == int(bo_qty) else bo_qty"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="po_number or '-'"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<t t-if="wo_job"><span t-esc="wo_job.name"/></t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="proc_label or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template id="report_fp_packing_slip_delivery_portrait">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
|
||||
|
||||
<!-- Model-agnostic resolution. This ONE template backs
|
||||
four report actions (sale.order, fp.job,
|
||||
fp.receiving, fusion.plating.delivery) so the
|
||||
packing slip prints from any of those screens.
|
||||
Resolve the sale order + ship-to per doc type, then
|
||||
render a common layout from the SO lines. (Template
|
||||
id kept as "...delivery..." for back-compat with the
|
||||
action + Python that reference it.) -->
|
||||
<t t-set="m" t-value="doc._name"/>
|
||||
<t t-set="_so" t-value="False"/>
|
||||
<t t-if="m == 'sale.order'">
|
||||
<t t-set="_so" t-value="doc"/>
|
||||
</t>
|
||||
<t t-elif="m == 'fp.job' or m == 'fp.receiving'">
|
||||
<t t-set="_so" t-value="doc.sale_order_id"/>
|
||||
</t>
|
||||
<t t-elif="m == 'fusion.plating.delivery'">
|
||||
<t t-set="_dlv_job" t-value="env['fp.job'].sudo().search([('name', '=', doc.job_ref)], limit=1) if doc.job_ref else env['fp.job']"/>
|
||||
<t t-set="_so" t-value="_dlv_job.sale_order_id if _dlv_job else False"/>
|
||||
</t>
|
||||
<t t-set="_lines" t-value="_so.order_line.filtered(lambda l: l.product_id and not l.display_type and l.product_uom_qty > 0) if _so else False"/>
|
||||
|
||||
<t t-set="ship_partner" t-value="(doc.delivery_address_id or doc.partner_id) if m == 'fusion.plating.delivery' else ((_so.partner_shipping_id or _so.partner_id) if _so else doc.partner_id)"/>
|
||||
<t t-set="bill_partner" t-value="(_so.partner_invoice_id if _so and _so.partner_invoice_id else (ship_partner.commercial_partner_id or ship_partner))"/>
|
||||
<t t-set="has_carrier" t-value="m == 'fusion.plating.delivery' and 'x_fc_carrier_id' in doc._fields and doc.x_fc_carrier_id"/>
|
||||
<t t-set="ship_via" t-value="(doc.x_fc_carrier_id.name if has_carrier else (_so.x_fc_ship_via if _so and 'x_fc_ship_via' in _so._fields and _so.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
|
||||
<t t-set="tracking_text" t-value="'Ready for pick up' if not has_carrier else '—'"/>
|
||||
<t t-set="po_number" t-value="(_so.client_order_ref if _so and _so.client_order_ref else '')"/>
|
||||
<t t-set="_scheduled" t-value="doc.scheduled_date if m == 'fusion.plating.delivery' else (doc.commitment_date if m == 'sale.order' else (_so.commitment_date if _so else False))"/>
|
||||
<t t-set="_notes" t-value="doc.notes if m == 'fusion.plating.delivery' else (doc.note if m == 'sale.order' else False)"/>
|
||||
|
||||
<t t-set="so_name_raw" t-value="_so.name if _so else (doc.name or '')"/>
|
||||
<t t-set="ps_number" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
|
||||
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
|
||||
<t t-set="qr_payload" t-value="doc.name or ''"/>
|
||||
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
|
||||
|
||||
<div class="fp-report fp-ps">
|
||||
<div class="page">
|
||||
<div class="fp-ps-titlebar">
|
||||
<t t-if="ps_barcode_uri">
|
||||
<div class="fp-ps-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
|
||||
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-ps-title-en">
|
||||
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
|
||||
</span>
|
||||
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
|
||||
</div>
|
||||
|
||||
<table class="bordered fp-ps-addrtable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Bill To:'"/>
|
||||
<t t-set="partner" t-value="bill_partner"/>
|
||||
</t>
|
||||
</td>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Ship To:'"/>
|
||||
<t t-set="partner" t-value="ship_partner"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="bordered fp-ps-info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 33%;">
|
||||
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 33%;">
|
||||
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 34%;">
|
||||
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span t-esc="ship_via"/></td>
|
||||
<td>
|
||||
<t t-if="_scheduled">
|
||||
<span t-out="_scheduled" t-options="{'widget': 'date'}"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</td>
|
||||
<td><span t-esc="tracking_text"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<t t-if="_lines">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_items_lines">
|
||||
<t t-set="lines" t-value="_lines"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p style="margin-top: 10px; color: #555;">
|
||||
No order lines found for this document
|
||||
(<span t-esc="doc.name or ''"/>).
|
||||
</p>
|
||||
</t>
|
||||
|
||||
<t t-if="_notes">
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Notes:</strong>
|
||||
<div t-out="_notes"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<record id="action_report_fp_packing_slip_delivery_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">fusion.plating.delivery</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_logistics.model_fusion_plating_delivery"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<!-- Same packing slip, exposed in the Print menu of the Sale Order,
|
||||
Work Order (fp.job) and Receiving/Shipping (fp.receiving) screens.
|
||||
All reuse the model-agnostic template above. -->
|
||||
<record id="action_report_fp_packing_slip_so_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_packing_slip_job_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_packing_slip_receiving_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">fp.receiving</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_receiving.model_fp_receiving"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user