Compare commits
22 Commits
a2277b481c
...
claude/ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d06ff77f | ||
|
|
53fe13344d | ||
|
|
423f288507 | ||
|
|
a86f20017d | ||
|
|
7426501555 | ||
|
|
3e787a1b24 | ||
|
|
6f006e24ad | ||
|
|
ba6aeaaca9 | ||
|
|
27577dd51a | ||
|
|
a10b7425f7 | ||
|
|
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
|
- Add new gotchas in the right format
|
||||||
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
||||||
- Know the deployment fact that fusion_portal is always co-installed
|
- Know the deployment fact that fusion_portal is always co-installed
|
||||||
|
|
||||||
|
## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`)
|
||||||
|
|
||||||
|
The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`),
|
||||||
|
so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this
|
||||||
|
wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix
|
||||||
|
the real cause — verify with a render, not by eye):
|
||||||
|
|
||||||
|
1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.**
|
||||||
|
The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under
|
||||||
|
`.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex;
|
||||||
|
margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`,
|
||||||
|
`.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property
|
||||||
|
the scoped rule doesn't set). Measured live: every wizard `.row` computed
|
||||||
|
`display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields
|
||||||
|
to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are
|
||||||
|
`sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class
|
||||||
|
that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …).
|
||||||
|
|
||||||
|
2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).**
|
||||||
|
SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{
|
||||||
|
grid-template-columns:1fr } … }` was nested high in the file, *before* the base
|
||||||
|
`.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have
|
||||||
|
the same specificity, so the later base rule overrode the media query — it was **dead**,
|
||||||
|
and the inner field-grids never collapsed to one column on a phone (fields crammed 2–3
|
||||||
|
across). `matchMedia('(max-width:560px)')` returned true while the columns stayed 2-up —
|
||||||
|
the tell that it's a cascade-order bug, not a media-match bug. **Fix:** all responsive
|
||||||
|
`@media` overrides live at the **end** of the `.o_service_booking { … }` block.
|
||||||
|
|
||||||
|
**How it was verified (do this, don't eyeball):** pull the live compiled bundle from prod
|
||||||
|
(`env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` returns `ir.attachment`
|
||||||
|
record(s) in Odoo 19 — read `.raw`, not a string), render the wizard markup against it with
|
||||||
|
the real web-client height/scroll chain (`html,body{height:100%}` → `.o_web_client` flex
|
||||||
|
column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard's
|
||||||
|
`height:100%;overflow:auto` scrolls) at 320/390/768/1280, and read computed
|
||||||
|
`grid-template-columns` / `margin-left` / `display`. A standalone vanilla-Bootstrap repro is
|
||||||
|
**not** faithful — it rendered fine and falsely cleared the bug.
|
||||||
|
|
||||||
|
## 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',
|
'name': 'Fusion Claims',
|
||||||
'version': '19.0.9.5.0',
|
'version': '19.0.9.7.0',
|
||||||
'category': 'Sales',
|
'category': 'Sales',
|
||||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -30,6 +30,44 @@ class ServiceBookingController(http.Controller):
|
|||||||
'lift': labour('labour_lift')},
|
'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')
|
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||||
def submit(self, payload=None, **kw):
|
def submit(self, payload=None, **kw):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** @odoo-module **/
|
/** @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 { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
@@ -11,10 +11,12 @@ export class ServiceBookingWizard extends Component {
|
|||||||
setup() {
|
setup() {
|
||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.rootRef = useRef("root");
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
custMode: "existing",
|
custMode: "existing",
|
||||||
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "" },
|
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "", lat: 0, lng: 0 },
|
||||||
partnerId: false, soSearch: "",
|
partnerId: false, soSearch: "", custResults: [], custSearching: false,
|
||||||
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
||||||
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
||||||
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
||||||
@@ -31,6 +33,104 @@ export class ServiceBookingWizard extends Component {
|
|||||||
labour: r.labour || this.state.labour,
|
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() {
|
get callout() {
|
||||||
if (this.state.inShop) return null;
|
if (this.state.inShop) return null;
|
||||||
|
|||||||
@@ -66,26 +66,17 @@
|
|||||||
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
||||||
|
|
||||||
.body { padding: 20px 24px 6px; }
|
.body { padding: 20px 24px 6px; }
|
||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
|
|
||||||
@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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.sb-card {
|
||||||
background: var(--sb-card);
|
background: var(--sb-card);
|
||||||
border: 1px solid var(--sb-border);
|
border: 1px solid var(--sb-border);
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
padding: 16px 17px;
|
padding: 16px 17px;
|
||||||
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
||||||
}
|
}
|
||||||
.card.span2 { grid-column: 1 / -1; }
|
.sb-card.span2 { grid-column: 1 / -1; }
|
||||||
.card h3 {
|
.sb-card h3 {
|
||||||
margin: 0 0 13px;
|
margin: 0 0 13px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -96,8 +87,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
.sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||||
.card h3 .tag {
|
.sb-card h3 .tag {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -109,8 +100,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
||||||
.row { margin-bottom: 12px; }
|
.sb-row { margin-bottom: 12px; }
|
||||||
.row:last-child { margin-bottom: 0; }
|
.sb-row:last-child { margin-bottom: 0; }
|
||||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
||||||
|
|
||||||
@@ -139,6 +130,28 @@
|
|||||||
.with-icon { position: relative; }
|
.with-icon { position: relative; }
|
||||||
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
|
.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 {
|
.seg {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: var(--sb-chip);
|
background: var(--sb-chip);
|
||||||
@@ -276,7 +289,7 @@
|
|||||||
}
|
}
|
||||||
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
||||||
|
|
||||||
.btn {
|
.sb-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 11px 18px;
|
padding: 11px 18px;
|
||||||
@@ -285,13 +298,32 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
.sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||||
.btn.primary {
|
.sb-btn.primary {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
||||||
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
||||||
}
|
}
|
||||||
.btn[disabled] { opacity: .6; cursor: not-allowed; }
|
.sb-btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||||
|
|
||||||
.hide { display: none !important; }
|
.hide { display: none !important; }
|
||||||
|
|
||||||
|
// Responsive overrides — MUST come AFTER the base layout rules above. These
|
||||||
|
// selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity
|
||||||
|
// as their base rules, so the cascade only lets the media query win when it is
|
||||||
|
// emitted later in the source. Previously this block sat right after .sb-grid
|
||||||
|
// (BEFORE the base .two/.three/.timepick rules), so the later base rules
|
||||||
|
// overrode it and the inner field-grids never collapsed to one column on a
|
||||||
|
// phone — fields crammed side-by-side. Keep these last.
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.sb-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.wrap { margin: 12px auto; padding: 0 10px; }
|
||||||
|
.body { padding: 14px 16px 4px; }
|
||||||
|
.topbar { padding: 14px 16px; }
|
||||||
|
.foot { padding: 14px 16px; flex-wrap: wrap; }
|
||||||
|
.two, .three { grid-template-columns: 1fr; }
|
||||||
|
.timepick { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
|
<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="wrap">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="grid">
|
<div class="sb-grid">
|
||||||
<!-- CUSTOMER -->
|
<!-- CUSTOMER -->
|
||||||
<div class="card">
|
<div class="sb-card">
|
||||||
<h3><span class="dot"></span>Customer</h3>
|
<h3><span class="dot"></span>Customer</h3>
|
||||||
<div class="row">
|
<div class="sb-row">
|
||||||
<div class="seg full">
|
<div class="seg full">
|
||||||
<button t-att-class="{ on: state.custMode === 'existing' }"
|
<button t-att-class="{ on: state.custMode === 'existing' }"
|
||||||
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
||||||
@@ -33,22 +33,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div t-if="state.custMode === 'existing'">
|
<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>
|
<label class="fl">Search by phone, name or SO</label>
|
||||||
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
|
<input class="f" t-att-value="state.soSearch" t-on-input="onCustSearch"
|
||||||
<div class="hint">Inbound call? Type the phone number — we match the contact & their history.</div>
|
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>
|
</div>
|
||||||
<div t-if="state.custMode === 'new'">
|
<div t-if="state.custMode === 'new'">
|
||||||
<div class="row two">
|
<div class="sb-row two">
|
||||||
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
||||||
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
<div class="sb-row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||||
<div class="row"><label class="fl">Address</label>
|
<div class="sb-row"><label class="fl">Address</label>
|
||||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row three">
|
<div class="sb-row three">
|
||||||
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||||
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||||
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
||||||
@@ -58,9 +70,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SERVICE & PRICING -->
|
<!-- SERVICE & PRICING -->
|
||||||
<div class="card">
|
<div class="sb-card">
|
||||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||||
<div class="row two">
|
<div class="sb-row two">
|
||||||
<div>
|
<div>
|
||||||
<label class="fl">Device being serviced</label>
|
<label class="fl">Device being serviced</label>
|
||||||
<select class="f" t-on-change="onDevice">
|
<select class="f" t-on-change="onDevice">
|
||||||
@@ -79,7 +91,7 @@
|
|||||||
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" t-if="!state.inShop">
|
<div class="sb-row" t-if="!state.inShop">
|
||||||
<label class="fl">Service call type</label>
|
<label class="fl">Service call type</label>
|
||||||
<select class="f"
|
<select class="f"
|
||||||
t-on-change="onCallType">
|
t-on-change="onCallType">
|
||||||
@@ -100,9 +112,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SCHEDULE -->
|
<!-- SCHEDULE -->
|
||||||
<div class="card">
|
<div class="sb-card">
|
||||||
<h3><span class="dot"></span>Schedule</h3>
|
<h3><span class="dot"></span>Schedule</h3>
|
||||||
<div class="row two">
|
<div class="sb-row two">
|
||||||
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
||||||
<div><label class="fl">Duration</label>
|
<div><label class="fl">Duration</label>
|
||||||
<select class="f" t-model.number="state.durationHr">
|
<select class="f" t-model.number="state.durationHr">
|
||||||
@@ -114,7 +126,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="sb-row">
|
||||||
<label class="fl">Start time</label>
|
<label class="fl">Start time</label>
|
||||||
<div class="timepick">
|
<div class="timepick">
|
||||||
<select class="f" t-model.number="state.hour">
|
<select class="f" t-model.number="state.hour">
|
||||||
@@ -140,7 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="sb-row">
|
||||||
<label class="fl">Technician</label>
|
<label class="fl">Technician</label>
|
||||||
<select class="f" t-model.number="state.technicianId">
|
<select class="f" t-model.number="state.technicianId">
|
||||||
<option value="">— Choose —</option>
|
<option value="">— Choose —</option>
|
||||||
@@ -152,17 +164,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LOCATION -->
|
<!-- LOCATION -->
|
||||||
<div class="card">
|
<div class="sb-card">
|
||||||
<h3><span class="dot"></span>Location</h3>
|
<h3><span class="dot"></span>Location</h3>
|
||||||
<div class="opt" style="border:none; padding-top:0;">
|
<div class="opt" style="border:none; padding-top:0;">
|
||||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
||||||
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
||||||
</div>
|
</div>
|
||||||
<div t-if="!state.inShop">
|
<div t-if="!state.inShop">
|
||||||
<div class="row"><label class="fl">Job address</label>
|
<div class="sb-row"><label class="fl">Job address</label>
|
||||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row two">
|
<div class="sb-row two">
|
||||||
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||||
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,11 +182,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JOB DETAILS -->
|
<!-- JOB DETAILS -->
|
||||||
<div class="card span2">
|
<div class="sb-card span2">
|
||||||
<h3><span class="dot"></span>Job details</h3>
|
<h3><span class="dot"></span>Job details</h3>
|
||||||
<div class="two">
|
<div class="two">
|
||||||
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
<div class="sb-row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||||
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
<div class="sb-row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
||||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
||||||
@@ -197,8 +209,8 @@
|
|||||||
|
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
||||||
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
<button class="sb-btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||||
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
<button class="sb-btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 |
|
| **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` |
|
| **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 |
|
| **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` |
|
| **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 |
|
| **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 |
|
| **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',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.9.3.0',
|
'version': '19.0.10.3.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Migrate the single Default CoC Contact (Many2one) to the multi-contact
|
||||||
|
Many2many.
|
||||||
|
|
||||||
|
The field x_fc_default_coc_contact_id (Many2one column on res_partner) was
|
||||||
|
renamed to x_fc_default_coc_contact_ids (self-referential Many2many, rel
|
||||||
|
table fp_default_coc_contact_rel). Odoo creates the new rel table during the
|
||||||
|
schema-update phase but leaves the old column orphaned. Copy each partner's
|
||||||
|
single contact into the new M2m so existing per-customer CoC routing carries
|
||||||
|
over, then drop the dead column. Idempotent + guarded on column existence.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
# Old column still present (Odoo doesn't drop removed-field columns)?
|
||||||
|
cr.execute("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'res_partner'
|
||||||
|
AND column_name = 'x_fc_default_coc_contact_id'
|
||||||
|
""")
|
||||||
|
if not cr.fetchone():
|
||||||
|
return
|
||||||
|
# New M2m rel table created by the schema update before this runs.
|
||||||
|
cr.execute("""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'fp_default_coc_contact_rel'
|
||||||
|
""")
|
||||||
|
if not cr.fetchone():
|
||||||
|
_logger.warning(
|
||||||
|
'fp_default_coc_contact_rel missing — skipping CoC contact '
|
||||||
|
'migration (rel table not created yet).')
|
||||||
|
return
|
||||||
|
# Copy the single value into the M2m (skip rows already present so a
|
||||||
|
# re-run is harmless).
|
||||||
|
cr.execute("""
|
||||||
|
INSERT INTO fp_default_coc_contact_rel (partner_id, contact_id)
|
||||||
|
SELECT p.id, p.x_fc_default_coc_contact_id
|
||||||
|
FROM res_partner p
|
||||||
|
WHERE p.x_fc_default_coc_contact_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM fp_default_coc_contact_rel r
|
||||||
|
WHERE r.partner_id = p.id
|
||||||
|
AND r.contact_id = p.x_fc_default_coc_contact_id)
|
||||||
|
""")
|
||||||
|
moved = cr.rowcount
|
||||||
|
cr.execute(
|
||||||
|
"ALTER TABLE res_partner DROP COLUMN IF EXISTS "
|
||||||
|
"x_fc_default_coc_contact_id")
|
||||||
|
_logger.info(
|
||||||
|
'CoC contact migration: copied %s single Default-CoC-Contact value(s) '
|
||||||
|
'into the new x_fc_default_coc_contact_ids M2m, dropped old column.',
|
||||||
|
moved)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Migrate the cert's single Customer Contact (Many2one) to a Many2many.
|
||||||
|
|
||||||
|
fp.certificate.contact_partner_id (single Many2one column) was renamed to
|
||||||
|
contact_partner_ids (Many2many -> res.partner, rel
|
||||||
|
fp_certificate_contact_partner_rel) so a cert can carry every contact who
|
||||||
|
receives the CoC. Odoo creates the new rel table during the schema-update
|
||||||
|
phase but leaves the old column orphaned. Copy each cert's single contact
|
||||||
|
into the new M2m (becomes the primary / printed addressee), then drop the
|
||||||
|
dead column. Guarded + idempotent.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
cr.execute("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'fp_certificate'
|
||||||
|
AND column_name = 'contact_partner_id'
|
||||||
|
""")
|
||||||
|
if not cr.fetchone():
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'fp_certificate_contact_partner_rel'
|
||||||
|
""")
|
||||||
|
if not cr.fetchone():
|
||||||
|
_logger.warning(
|
||||||
|
'fp_certificate_contact_partner_rel missing — skipping cert '
|
||||||
|
'customer-contact migration (rel table not created yet).')
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
INSERT INTO fp_certificate_contact_partner_rel (cert_id, partner_id)
|
||||||
|
SELECT c.id, c.contact_partner_id
|
||||||
|
FROM fp_certificate c
|
||||||
|
WHERE c.contact_partner_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM fp_certificate_contact_partner_rel r
|
||||||
|
WHERE r.cert_id = c.id
|
||||||
|
AND r.partner_id = c.contact_partner_id)
|
||||||
|
""")
|
||||||
|
moved = cr.rowcount
|
||||||
|
cr.execute(
|
||||||
|
"ALTER TABLE fp_certificate DROP COLUMN IF EXISTS contact_partner_id")
|
||||||
|
_logger.info(
|
||||||
|
'Cert customer-contact migration: copied %s single contact_partner_id '
|
||||||
|
'value(s) into the new contact_partner_ids M2m, dropped old column.',
|
||||||
|
moved)
|
||||||
@@ -58,11 +58,18 @@ class FpCertificate(models.Model):
|
|||||||
string='Customer Job No.',
|
string='Customer Job No.',
|
||||||
help="Customer's internal job / traveler reference.",
|
help="Customer's internal job / traveler reference.",
|
||||||
)
|
)
|
||||||
contact_partner_id = fields.Many2one(
|
contact_partner_ids = fields.Many2many(
|
||||||
'res.partner', string='Customer Contact',
|
'res.partner',
|
||||||
|
relation='fp_certificate_contact_partner_rel',
|
||||||
|
column1='cert_id', column2='partner_id',
|
||||||
|
string='Customer Contact',
|
||||||
domain="[('parent_id', '=', partner_id)]",
|
domain="[('parent_id', '=', partner_id)]",
|
||||||
help="Specific contact person at the customer for this certificate. "
|
help="Contacts at the customer who receive this certificate. "
|
||||||
'Their name, email, and phone are printed on the CoC.',
|
"Auto-filled from the customer's Default CoC Contacts when a "
|
||||||
|
'job ships. The first is the primary (its name, email and '
|
||||||
|
'phone print on the CoC); ALL of them are emailed when the '
|
||||||
|
'cert is sent to the customer. (Renamed from the single '
|
||||||
|
'contact_partner_id — see migration 19.0.10.3.0.)',
|
||||||
)
|
)
|
||||||
issued_by_id = fields.Many2one(
|
issued_by_id = fields.Many2one(
|
||||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||||
@@ -436,6 +443,47 @@ class FpCertificate(models.Model):
|
|||||||
rec.invalidate_recordset(['name'])
|
rec.invalidate_recordset(['name'])
|
||||||
return records
|
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 ----------------------------------------------------
|
# ----- State actions ----------------------------------------------------
|
||||||
def action_issue(self):
|
def action_issue(self):
|
||||||
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
# ===== 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
|
# was configured would still trip the gate even after sales
|
||||||
# set the default. Robust-by-construction: the defaults take
|
# set the default. Robust-by-construction: the defaults take
|
||||||
# effect retroactively at issue time.
|
# effect retroactively at issue time.
|
||||||
if (not rec.contact_partner_id
|
if (not rec.contact_partner_ids
|
||||||
and rec.partner_id
|
and rec.partner_id
|
||||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
|
||||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||||
rec.contact_partner_id = (
|
# Auto-fill ALL the customer's CoC contacts. The first is
|
||||||
rec.partner_id.x_fc_default_coc_contact_id
|
# the primary (printed on the CoC); every contact is emailed
|
||||||
|
# when the cert is sent (action_send_to_customer).
|
||||||
|
rec.contact_partner_ids = (
|
||||||
|
rec.partner_id.x_fc_default_coc_contact_ids
|
||||||
)
|
)
|
||||||
# Lazy-fill the signer from the LIVE company owner (Settings
|
# Lazy-fill the signer from the LIVE company owner (Settings
|
||||||
# "Certificate Owner") when no per-cert / per-spec signer was
|
# "Certificate Owner") when no per-cert / per-spec signer was
|
||||||
@@ -537,27 +588,27 @@ class FpCertificate(models.Model):
|
|||||||
'(Settings > Fusion Plating).'
|
'(Settings > Fusion Plating).'
|
||||||
) % {'name': rec.name or rec.display_name})
|
) % {'name': rec.name or rec.display_name})
|
||||||
# Customer contact — the named recipient printed on the
|
# Customer contact — the named recipient printed on the
|
||||||
# cert and emailed when it ships. Auto-filled from
|
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||||
# partner.x_fc_default_coc_contact_id when set.
|
# of partner.x_fc_default_coc_contact_ids when set.
|
||||||
if not rec.contact_partner_id:
|
if not rec.contact_partner_ids:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Cannot issue certificate "%(name)s" — Customer '
|
'Cannot issue certificate "%(name)s" — Customer '
|
||||||
'Contact is not set.\n\nPick the recipient contact, '
|
'Contact is not set.\n\nPick the recipient contact(s), '
|
||||||
'or configure a Default CoC Contact on customer '
|
'or configure Default CoC Contacts on customer '
|
||||||
'"%(cust)s".'
|
'"%(cust)s".'
|
||||||
) % {
|
) % {
|
||||||
'name': rec.name or rec.display_name,
|
'name': rec.name or rec.display_name,
|
||||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||||
})
|
})
|
||||||
if not (rec.contact_partner_id.email or '').strip():
|
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Cannot issue certificate "%(name)s" — contact '
|
'Cannot issue certificate "%(name)s" — primary contact '
|
||||||
'"%(c)s" has no email address.\n\nAdd an email '
|
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||||
'to the contact before issuing (the cert is sent '
|
'contact before issuing (the cert is sent by email '
|
||||||
'by email post-issue).'
|
'post-issue).'
|
||||||
) % {
|
) % {
|
||||||
'name': rec.name or rec.display_name,
|
'name': rec.name or rec.display_name,
|
||||||
'c': rec.contact_partner_id.name,
|
'c': rec.contact_partner_ids[:1].name,
|
||||||
})
|
})
|
||||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||||
# are manual-attach only — operator uploads supplier doc /
|
# are manual-attach only — operator uploads supplier doc /
|
||||||
@@ -580,30 +631,18 @@ class FpCertificate(models.Model):
|
|||||||
'type': type_label,
|
'type': type_label,
|
||||||
'name': rec.name or rec.display_name,
|
'name': rec.name or rec.display_name,
|
||||||
})
|
})
|
||||||
# Thickness data requirement — unified gate covering both
|
# Thickness data requirement — _fp_needs_thickness_data is the
|
||||||
# cert types. A customer needs thickness data on the cert
|
# single source of truth (shared with the Issue-Certs wizard).
|
||||||
# when ANY of these is true:
|
# A customer needs thickness data when the cert is a
|
||||||
# 1. cert type is thickness_report (the cert IS the data)
|
# thickness_report, or it's a CoC and the partner is
|
||||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
# strict-thickness / opts into the thickness-on-CoC bundle —
|
||||||
# Nadcap — always strict)
|
# UNLESS the job's recipe suppresses thickness
|
||||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
# (requires_thickness_report=False: passivation, chemical
|
||||||
# rule — CoC carries thickness as page 2 by default
|
# conversion, anodize seal-only — no plating thickness exists).
|
||||||
# for these customers; see CLAUDE.md "CoC + thickness
|
# Acceptable data: logged readings on the cert OR a Fischerscope
|
||||||
# = ONE cert (page 2 merge)")
|
# PDF on the linked QC OR a cert-local Fischerscope upload.
|
||||||
# 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.
|
|
||||||
partner = rec.partner_id
|
partner = rec.partner_id
|
||||||
needs_thickness = (
|
if rec._fp_needs_thickness_data():
|
||||||
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:
|
|
||||||
has_readings = bool(rec.thickness_reading_ids)
|
has_readings = bool(rec.thickness_reading_ids)
|
||||||
has_qc_fischer_pdf = bool(
|
has_qc_fischer_pdf = bool(
|
||||||
rec.x_fc_thickness_pdf_id
|
rec.x_fc_thickness_pdf_id
|
||||||
@@ -1085,11 +1124,18 @@ class FpCertificate(models.Model):
|
|||||||
"""Open email composer with the certificate PDF attached."""
|
"""Open email composer with the certificate PDF attached."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
||||||
|
# Send to ALL the Customer Contacts defined on this cert (auto-filled
|
||||||
|
# from the customer's Default CoC Contacts at job creation), falling
|
||||||
|
# back to the company. The CoC goes to exactly the contacts shown on
|
||||||
|
# the cert's Customer Contact field.
|
||||||
|
recipients = self.contact_partner_ids
|
||||||
|
partner_ids = recipients.ids or (
|
||||||
|
[self.partner_id.id] if self.partner_id else [])
|
||||||
ctx = {
|
ctx = {
|
||||||
'default_model': 'fp.certificate',
|
'default_model': 'fp.certificate',
|
||||||
'default_res_ids': self.ids,
|
'default_res_ids': self.ids,
|
||||||
'default_composition_mode': 'comment',
|
'default_composition_mode': 'comment',
|
||||||
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
|
'default_partner_ids': partner_ids,
|
||||||
}
|
}
|
||||||
if self.attachment_id:
|
if self.attachment_id:
|
||||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||||
|
|||||||
@@ -128,17 +128,24 @@ class ResPartner(models.Model):
|
|||||||
'who require specific NIST or DFARS language.',
|
'who require specific NIST or DFARS language.',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
# ---- Default CoC contacts (cert addressees + email recipients) -------
|
||||||
# The single named contact printed on the CoC and used as the email
|
# One or more named contacts who receive this customer's CoC. The first
|
||||||
# default when the cert ships. Sales sets it once per customer.
|
# is the primary (printed on the cert + pre-fills cert.contact_partner_id
|
||||||
# Falls back to manual selection at action_issue time if blank.
|
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
|
||||||
x_fc_default_coc_contact_id = fields.Many2one(
|
# sets the list once per customer. Falls back to manual selection at
|
||||||
|
# action_issue time if blank. Self-referential M2m (renamed from the old
|
||||||
|
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
|
||||||
|
x_fc_default_coc_contact_ids = fields.Many2many(
|
||||||
'res.partner',
|
'res.partner',
|
||||||
string='Default CoC Contact',
|
relation='fp_default_coc_contact_rel',
|
||||||
|
column1='partner_id', column2='contact_id',
|
||||||
|
string='Default CoC Contacts',
|
||||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||||
tracking=True,
|
tracking=True,
|
||||||
help='Default contact the Certificate of Conformance is addressed '
|
help='Contacts the Certificate of Conformance is addressed to and '
|
||||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
'emailed to. The first contact is the primary (printed on the '
|
||||||
'job ships. Leave blank to force the manager to pick at '
|
'cert and pre-filled as the cert contact when a job ships); '
|
||||||
'issue time. Must be a child contact of this company.',
|
'the rest are copied (CC) when the CoC is sent. Leave blank to '
|
||||||
|
'force the manager to pick at issue time. Child contacts of '
|
||||||
|
'this company only.',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase):
|
|||||||
'spec_reference': 'AMS 2404',
|
'spec_reference': 'AMS 2404',
|
||||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||||
'certified_by_id': self.signer.id,
|
'certified_by_id': self.signer.id,
|
||||||
'contact_partner_id': self.contact_with_email.id,
|
'contact_partner_ids': [(6, 0, [self.contact_with_email.id])],
|
||||||
}
|
}
|
||||||
vals.update(kw)
|
vals.update(kw)
|
||||||
return self.env['fp.certificate'].create(vals)
|
return self.env['fp.certificate'].create(vals)
|
||||||
@@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase):
|
|||||||
# ---- new gate: contact_partner_id ----
|
# ---- new gate: contact_partner_id ----
|
||||||
|
|
||||||
def test_blocks_on_missing_contact(self):
|
def test_blocks_on_missing_contact(self):
|
||||||
cert = self._make_cert(contact_partner_id=False)
|
cert = self._make_cert(contact_partner_ids=False)
|
||||||
with self.assertRaises(UserError) as exc:
|
with self.assertRaises(UserError) as exc:
|
||||||
cert.action_issue()
|
cert.action_issue()
|
||||||
self.assertIn('Customer Contact', str(exc.exception))
|
self.assertIn('Customer Contact', str(exc.exception))
|
||||||
|
|
||||||
def test_blocks_on_contact_without_email(self):
|
def test_blocks_on_contact_without_email(self):
|
||||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
cert = self._make_cert(contact_partner_ids=[(6, 0, [self.contact_no_email.id])])
|
||||||
with self.assertRaises(UserError) as exc:
|
with self.assertRaises(UserError) as exc:
|
||||||
cert.action_issue()
|
cert.action_issue()
|
||||||
self.assertIn('no email', str(exc.exception))
|
self.assertIn('no email', str(exc.exception))
|
||||||
@@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase):
|
|||||||
spec_reference=False,
|
spec_reference=False,
|
||||||
process_description=False,
|
process_description=False,
|
||||||
certified_by_id=False,
|
certified_by_id=False,
|
||||||
contact_partner_id=False,
|
contact_partner_ids=False,
|
||||||
)
|
)
|
||||||
with self.assertRaises(UserError) as exc:
|
with self.assertRaises(UserError) as exc:
|
||||||
cert.action_issue()
|
cert.action_issue()
|
||||||
|
|||||||
@@ -101,7 +101,8 @@
|
|||||||
<group>
|
<group>
|
||||||
<field name="certificate_type"/>
|
<field name="certificate_type"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="contact_partner_id"
|
<field name="contact_partner_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
options="{'no_create': True}"
|
options="{'no_create': True}"
|
||||||
invisible="not partner_id"/>
|
invisible="not partner_id"/>
|
||||||
<field name="sale_order_id"/>
|
<field name="sale_order_id"/>
|
||||||
|
|||||||
@@ -44,15 +44,17 @@
|
|||||||
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
|
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<separator string="Default CoC Contact"/>
|
<separator string="Default CoC Contacts"/>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
The named contact this customer's CoC is addressed
|
The contacts this customer's CoC is addressed to and
|
||||||
to and emailed to. Pre-fills cert records when a
|
emailed to. The first is the primary (printed on the
|
||||||
job ships. Leave blank to force the manager to pick
|
cert); the rest are copied (CC) when the CoC is sent.
|
||||||
at issue time.
|
Pre-fills cert records when a job ships. Leave blank
|
||||||
|
to force the manager to pick at issue time.
|
||||||
</p>
|
</p>
|
||||||
<group>
|
<group>
|
||||||
<field name="x_fc_default_coc_contact_id"
|
<field name="x_fc_default_coc_contact_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
options="{'no_create': True}"/>
|
options="{'no_create': True}"/>
|
||||||
</group>
|
</group>
|
||||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.12.1.6',
|
'version': '19.0.12.4.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -2379,7 +2379,14 @@ class FpJob(models.Model):
|
|||||||
if 'contact_name' in Delivery._fields and ship_to.name:
|
if 'contact_name' in Delivery._fields and ship_to.name:
|
||||||
vals['contact_name'] = ship_to.name
|
vals['contact_name'] = ship_to.name
|
||||||
if 'contact_phone' in Delivery._fields:
|
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
|
# Scheduled date — operator can adjust; this just primes it
|
||||||
# so they're not staring at a blank field.
|
# so they're not staring at a blank field.
|
||||||
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
|
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
|
(a per-spec override). Left empty
|
||||||
otherwise so the LIVE company owner
|
otherwise so the LIVE company owner
|
||||||
resolves at render / issue time.
|
resolves at render / issue time.
|
||||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
|
||||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||||
|
|
||||||
Honours part.certificate_requirement (coc / coc_thickness /
|
Honours part.certificate_requirement (coc / coc_thickness /
|
||||||
@@ -2706,8 +2713,11 @@ class FpJob(models.Model):
|
|||||||
signer = spec.signer_user_id
|
signer = spec.signer_user_id
|
||||||
# Contact: per-customer default; blank means manager picks at issue.
|
# Contact: per-customer default; blank means manager picks at issue.
|
||||||
contact = False
|
contact = False
|
||||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
# ALL the customer's CoC contacts -> the cert's Customer Contact
|
||||||
|
# M2m. First is the primary (printed on the CoC); every contact
|
||||||
|
# is emailed at send (fp.certificate.action_send_to_customer).
|
||||||
|
contact = self.partner_id.x_fc_default_coc_contact_ids
|
||||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||||
nc_qty = int(
|
nc_qty = int(
|
||||||
(self.qty_scrapped or 0)
|
(self.qty_scrapped or 0)
|
||||||
@@ -2777,8 +2787,8 @@ class FpJob(models.Model):
|
|||||||
vals['process_description'] = recipe.name or ''
|
vals['process_description'] = recipe.name or ''
|
||||||
if 'certified_by_id' in Cert._fields and signer:
|
if 'certified_by_id' in Cert._fields and signer:
|
||||||
vals['certified_by_id'] = signer.id
|
vals['certified_by_id'] = signer.id
|
||||||
if 'contact_partner_id' in Cert._fields and contact:
|
if 'contact_partner_ids' in Cert._fields and contact:
|
||||||
vals['contact_partner_id'] = contact.id
|
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
|
||||||
if 'entech_wo_number' in Cert._fields:
|
if 'entech_wo_number' in Cert._fields:
|
||||||
vals['entech_wo_number'] = self.name or ''
|
vals['entech_wo_number'] = self.name or ''
|
||||||
cert = Cert.create(vals)
|
cert = Cert.create(vals)
|
||||||
|
|||||||
@@ -486,6 +486,45 @@ class FpJobStep(models.Model):
|
|||||||
step.state = 'cancelled'
|
step.state = 'cancelled'
|
||||||
return True
|
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):
|
def write(self, vals):
|
||||||
"""Post a chatter trail on the parent JOB whenever an active
|
"""Post a chatter trail on the parent JOB whenever an active
|
||||||
step gets reassigned. The step itself already tracks
|
step gets reassigned. The step itself already tracks
|
||||||
@@ -846,6 +885,16 @@ class FpJobStep(models.Model):
|
|||||||
Prompt = self.env['fusion.plating.process.node.input']
|
Prompt = self.env['fusion.plating.process.node.input']
|
||||||
if not node:
|
if not node:
|
||||||
return Prompt
|
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
|
prompts = node.input_ids
|
||||||
if 'kind' in prompts._fields:
|
if 'kind' in prompts._fields:
|
||||||
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ def _clean(text):
|
|||||||
t = str(text)
|
t = str(text)
|
||||||
for a, b in ((u'—', '-'), (u'–', '-'), (u'‘', "'"),
|
for a, b in ((u'—', '-'), (u'–', '-'), (u'‘', "'"),
|
||||||
(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)
|
t = t.replace(a, b)
|
||||||
return t.strip()
|
return t.strip()
|
||||||
|
|
||||||
@@ -88,6 +94,9 @@ class FpJob(models.Model):
|
|||||||
'qty': qty,
|
'qty': qty,
|
||||||
'due': due_s,
|
'due': due_s,
|
||||||
'thk': thk,
|
'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),
|
'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 '',
|
'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 '',
|
'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'.
|
# reaches state='done'.
|
||||||
|
|
||||||
from odoo import _, fields, models
|
from odoo import _, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpReceiving(models.Model):
|
class FpReceiving(models.Model):
|
||||||
@@ -65,3 +66,34 @@ class FpReceiving(models.Model):
|
|||||||
if len(jobs) == 1:
|
if len(jobs) == 1:
|
||||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||||
return action
|
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_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="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="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
<!-- NOT bound to the fp.job Print menu (removed from the list per
|
||||||
<field name="binding_type">report</field>
|
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"/>
|
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
@@ -84,13 +87,13 @@
|
|||||||
/* Layout B rail + main */
|
/* Layout B rail + main */
|
||||||
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
|
.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; }
|
.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 { height: 9mm; line-height: 9mm; text-align: center; }
|
||||||
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
|
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
|
||||||
.r-wo { height: 14mm; background: #000; color: #fff; padding: 0; }
|
.r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
|
||||||
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||||
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||||
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
|
.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 { border-collapse: collapse; width: 100%; height: 100%; }
|
||||||
.qftbl td { vertical-align: middle; text-align: center; }
|
.qftbl td { vertical-align: middle; text-align: center; }
|
||||||
.qfqr { width: 66%; }
|
.qfqr { width: 66%; }
|
||||||
@@ -104,72 +107,23 @@
|
|||||||
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
|
.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 { 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; }
|
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
|
||||||
.qffull { line-height: 36mm; }
|
.qffull { line-height: 32mm; }
|
||||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; }
|
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||||
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; }
|
.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, bigger box. */
|
/* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
|
||||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; }
|
30mm -> 27mm so the QR-driven black band is shorter and the freed
|
||||||
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; }
|
height flows to the NOTES block below. Still well above scan size. */
|
||||||
.r-fld { padding: 1mm 2.2mm; }
|
.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 { border-collapse: collapse; width: 100%; height: 100%; }
|
||||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
.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-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||||
</style>
|
</style>
|
||||||
</template>
|
</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 ===================== -->
|
<!-- ===================== External body — Layout B ===================== -->
|
||||||
<template id="fp_job_external_body">
|
<template id="fp_job_external_body">
|
||||||
<div class="label-page"><div class="label">
|
<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>
|
<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>
|
</tr></table></div>
|
||||||
<div style="height:8.5mm"><table class="gtbl"><tr>
|
<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">Due</span><span style="font-size:11pt;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>
|
|
||||||
</tr></table></div>
|
</tr></table></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<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']">
|
<t t-if="d['bake']">
|
||||||
<div class="m-bake rule"><span class="inshead">BAKE</span>
|
<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 class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</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 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,16 +194,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ===================== Internal outer (per job) ===================== -->
|
<!-- ===================== 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">
|
<template id="report_fp_job_sticker_internal_template">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||||
<t t-foreach="docs" t-as="job">
|
<t t-foreach="docs" t-as="job">
|
||||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||||
<t t-set="_note" t-value="d['internal_notes']"/>
|
<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="_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="_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-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||||
<t t-call="fusion_plating_jobs.fp_job_internal_body"/>
|
<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>
|
||||||
</t>
|
</t>
|
||||||
</template>
|
</template>
|
||||||
@@ -254,6 +234,7 @@
|
|||||||
<t t-foreach="docs" t-as="job">
|
<t t-foreach="docs" t-as="job">
|
||||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||||
<t t-set="_note" t-value="d['customer_notes']"/>
|
<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="_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="_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="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
|
|||||||
'name': 'CertCust',
|
'name': 'CertCust',
|
||||||
'is_company': True,
|
'is_company': True,
|
||||||
'x_fc_send_coc': True,
|
'x_fc_send_coc': True,
|
||||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
'x_fc_default_coc_contact_ids': [(6, 0, [cls.contact.id])],
|
||||||
})
|
})
|
||||||
cls.contact.parent_id = cls.partner.id
|
cls.contact.parent_id = cls.partner.id
|
||||||
cls.product = cls.env['product.product'].create({
|
cls.product = cls.env['product.product'].create({
|
||||||
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
|
|||||||
cert = self.env['fp.certificate'].search([
|
cert = self.env['fp.certificate'].search([
|
||||||
('x_fc_job_id', '=', job.id),
|
('x_fc_job_id', '=', job.id),
|
||||||
])
|
])
|
||||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
self.assertEqual(cert.contact_partner_ids, self.contact)
|
||||||
|
|
||||||
def test_create_cert_computes_nc_quantity(self):
|
def test_create_cert_computes_nc_quantity(self):
|
||||||
job = self._make_job(
|
job = self._make_job(
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
|
|||||||
'certificate_type': 'nadcap_cert',
|
'certificate_type': 'nadcap_cert',
|
||||||
'state': 'draft',
|
'state': 'draft',
|
||||||
'partner_id': self.partner.id,
|
'partner_id': self.partner.id,
|
||||||
'contact_partner_id': self.partner.id,
|
'contact_partner_ids': [(6, 0, [self.partner.id])],
|
||||||
'spec_reference': 'AMS 2404',
|
'spec_reference': 'AMS 2404',
|
||||||
'process_description': 'TEST PROCESS',
|
'process_description': 'TEST PROCESS',
|
||||||
'certified_by_id': self.env.user.id,
|
'certified_by_id': self.env.user.id,
|
||||||
@@ -141,3 +141,48 @@ class TestRecipeCertSuppression(TransactionCase):
|
|||||||
cert.with_context(
|
cert.with_context(
|
||||||
fp_skip_cert_authority_gate=True
|
fp_skip_cert_authority_gate=True
|
||||||
).action_issue()
|
).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>
|
||||||
<xpath expr="//field[@name='product_id']" position="after">
|
<xpath expr="//field[@name='product_id']" position="after">
|
||||||
<field name="part_catalog_id" string="Part"/>
|
<field name="part_catalog_id" string="Part"/>
|
||||||
<field name="customer_spec_id" string="Specification"/>
|
<field name="customer_spec_id" string="Specification" invisible="1"/>
|
||||||
<field name="recipe_id" string="Process Recipe"/>
|
<field name="recipe_id" string="Process Recipe" readonly="1"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<!-- Show qty completed alongside total so the partial-qty
|
<!-- Show qty completed alongside total so the partial-qty
|
||||||
picture is visible at a glance without opening Move Log. -->
|
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">
|
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
|
||||||
<field name="step_ids" mode="list"
|
<field name="step_ids" mode="list"
|
||||||
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}">
|
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-info="state in ('ready', 'in_progress')"
|
||||||
decoration-success="state == 'done'"
|
decoration-success="state == 'done'"
|
||||||
decoration-warning="state == 'paused'"
|
decoration-warning="state == 'paused'"
|
||||||
@@ -162,6 +162,14 @@
|
|||||||
title="Finish & Next" icon="fa-check-circle"
|
title="Finish & Next" icon="fa-check-circle"
|
||||||
class="btn-link o_fp_finish_btn"
|
class="btn-link o_fp_finish_btn"
|
||||||
invisible="state != 'in_progress'"/>
|
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
|
<!-- Secondary actions — small icons only. Pause is
|
||||||
only relevant on a running step; Record Inputs
|
only relevant on a running step; Record Inputs
|
||||||
|
|||||||
@@ -25,6 +25,17 @@
|
|||||||
class="btn-primary" icon="fa-cogs"
|
class="btn-primary" icon="fa-cogs"
|
||||||
invisible="not x_fc_show_work_order_btn"
|
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."/>
|
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>
|
</xpath>
|
||||||
|
|
||||||
<!-- Work Order smart button on the button_box (mirrors the
|
<!-- Work Order smart button on the button_box (mirrors the
|
||||||
|
|||||||
@@ -461,17 +461,18 @@ class FpCertIssueWizardLine(models.TransientModel):
|
|||||||
|
|
||||||
@api.depends('cert_id.certificate_type',
|
@api.depends('cert_id.certificate_type',
|
||||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
'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):
|
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:
|
for ln in self:
|
||||||
cert = ln.cert_id
|
|
||||||
partner = cert.partner_id
|
|
||||||
ln.needs_thickness = (
|
ln.needs_thickness = (
|
||||||
cert.certificate_type == 'thickness_report'
|
ln.cert_id._fp_needs_thickness_data()
|
||||||
or (cert.certificate_type == 'coc' and partner and (
|
if ln.cert_id else False
|
||||||
partner.x_fc_strict_thickness_required
|
|
||||||
or partner.x_fc_send_thickness_report
|
|
||||||
))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FpDelivery(models.Model):
|
class FpDelivery(models.Model):
|
||||||
"""Scheduled delivery of finished parts back to a customer.
|
"""Scheduled delivery of finished parts back to a customer.
|
||||||
@@ -480,6 +485,51 @@ class FpDelivery(models.Model):
|
|||||||
or rec.vehicle_id.display_name
|
or rec.vehicle_id.display_name
|
||||||
or 'Driver'),
|
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):
|
def action_mark_delivered(self):
|
||||||
"""Block "delivered" until a Proof of Delivery exists.
|
"""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
|
# Sub 8 — box-parity warning. Non-blocking; just posts to
|
||||||
# chatter so the shipping supervisor sees it on the record.
|
# chatter so the shipping supervisor sees it on the record.
|
||||||
rec._fp_check_box_parity()
|
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):
|
def _fp_check_box_parity(self):
|
||||||
"""Compare this delivery's boxes-out count to the boxes-in count
|
"""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)
|
# Packing slip — gated by customer preference (default True)
|
||||||
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
|
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
|
||||||
att = _render_report(
|
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:
|
if att:
|
||||||
ids.append(att)
|
ids.append(att)
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ class SaleOrder(models.Model):
|
|||||||
'expected_qty': int(total_qty),
|
'expected_qty': int(total_qty),
|
||||||
'line_ids': line_vals,
|
'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
|
return res
|
||||||
|
|
||||||
def action_view_receiving(self):
|
def action_view_receiving(self):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.11.34.0',
|
'version': '19.0.11.36.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
# creates a cycle. Our only fp.job touchpoint is wo_scan.py which
|
# creates a cycle. Our only fp.job touchpoint is wo_scan.py which
|
||||||
# uses runtime env.get('fp.job') — safe without the manifest dep.
|
# uses runtime env.get('fp.job') — safe without the manifest dep.
|
||||||
'fusion_plating_logistics',
|
'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': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
|||||||
@@ -216,7 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 50%; vertical-align: top;">
|
<td style="width: 50%; vertical-align: top;">
|
||||||
<t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/>
|
<t t-set="contact" t-value="doc.contact_partner_ids[:1] or doc.partner_id"/>
|
||||||
<div>
|
<div>
|
||||||
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
|
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
|
||||||
<t t-esc="contact.name or ''"/>
|
<t t-esc="contact.name or ''"/>
|
||||||
|
|||||||
@@ -461,4 +461,271 @@
|
|||||||
</t>
|
</t>
|
||||||
</template>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Fusion Shipping",
|
"name": "Fusion Shipping",
|
||||||
"version": "19.0.1.5.0",
|
"version": "19.0.1.7.0",
|
||||||
"category": "Inventory/Delivery",
|
"category": "Inventory/Delivery",
|
||||||
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
||||||
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
||||||
|
|||||||
@@ -431,9 +431,11 @@ class UPSRequest:
|
|||||||
(ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or
|
(ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or
|
||||||
(ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR')
|
(ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR')
|
||||||
):
|
):
|
||||||
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = {
|
# reference_number is already a list of {'Code', 'Value'} dicts
|
||||||
'Value': shipment_info.get('reference_number')
|
# (see _ups_rest_prepare_shipping_data). UPS expects ReferenceNumber
|
||||||
}
|
# to be such an object (or an array of them) with a string Value --
|
||||||
|
# NOT {'Value': [<dict>, ...]}, which UPS rejects on the ship call.
|
||||||
|
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = shipment_info.get('reference_number')
|
||||||
|
|
||||||
# Shipments from US to CA or PR require extra info
|
# Shipments from US to CA or PR require extra info
|
||||||
if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']:
|
if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']:
|
||||||
|
|||||||
@@ -242,9 +242,12 @@ class DeliveryCarrier(models.Model):
|
|||||||
('EPL', 'EPL'),
|
('EPL', 'EPL'),
|
||||||
('SPL', 'SPL')],
|
('SPL', 'SPL')],
|
||||||
string="UPS Label File Type", default='GIF')
|
string="UPS Label File Type", default='GIF')
|
||||||
ups_bill_my_account = fields.Boolean(string='Bill My Account',
|
ups_bill_my_account = fields.Boolean(string="Bill recipient's UPS account",
|
||||||
help="If checked, ecommerce users will be prompted their UPS account number\n"
|
help="When the customer has a UPS account number on file (set on the "
|
||||||
"and delivery fees will be charged on it.")
|
"customer's contact, Sales & Purchase tab), charge UPS shipping to "
|
||||||
|
"THEIR account instead of yours (UPS 'Bill Receiver'). The shipping "
|
||||||
|
"line on the order is set to $0 since the customer pays. Customers "
|
||||||
|
"with no account on file are billed to your account at the normal rate.")
|
||||||
ups_saturday_delivery = fields.Boolean(string='UPS Saturday Delivery',
|
ups_saturday_delivery = fields.Boolean(string='UPS Saturday Delivery',
|
||||||
help='This value added service will allow you to ship the package on saturday also.')
|
help='This value added service will allow you to ship the package on saturday also.')
|
||||||
ups_cod_funds_code = fields.Selection(selection=[
|
ups_cod_funds_code = fields.Selection(selection=[
|
||||||
@@ -261,6 +264,16 @@ class DeliveryCarrier(models.Model):
|
|||||||
ups_access_token = fields.Char(string='UPS Access Token', groups="base.group_system")
|
ups_access_token = fields.Char(string='UPS Access Token', groups="base.group_system")
|
||||||
ups_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type')
|
ups_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type')
|
||||||
ups_require_signature = fields.Boolean("Require Signature")
|
ups_require_signature = fields.Boolean("Require Signature")
|
||||||
|
ups_rest_documentation_type = fields.Selection(
|
||||||
|
[('none', 'No'),
|
||||||
|
('invoice', 'UPS commercial invoice (paperless if account enrolled)')],
|
||||||
|
string='Generate customs invoice', default='invoice',
|
||||||
|
help="For international shipments, have UPS generate the commercial invoice "
|
||||||
|
"from the order data (UPS 'International Forms') and attach the PDF to the "
|
||||||
|
"delivery. If your UPS account is enrolled in Paperless Invoice, UPS also "
|
||||||
|
"submits it electronically to customs/the recipient (and you avoid UPS's "
|
||||||
|
"paper-invoice surcharge). Requires an HS code and country of origin on the "
|
||||||
|
"shipped products. No invoice is generated for domestic shipments.")
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
# FEDEX SOAP (Legacy) FIELDS
|
# FEDEX SOAP (Legacy) FIELDS
|
||||||
@@ -1892,7 +1905,7 @@ class DeliveryCarrier(models.Model):
|
|||||||
currency_code = picking.sale_id.currency_id.name
|
currency_code = picking.sale_id.currency_id.name
|
||||||
|
|
||||||
shipment_info = {
|
shipment_info = {
|
||||||
'require_invoice': picking._should_generate_commercial_invoice(),
|
'require_invoice': (self.ups_rest_documentation_type != 'none') and picking._should_generate_commercial_invoice(),
|
||||||
'invoice_date': fields.Date.today().strftime('%Y%m%d'),
|
'invoice_date': fields.Date.today().strftime('%Y%m%d'),
|
||||||
'description': picking.origin or picking.name,
|
'description': picking.origin or picking.name,
|
||||||
'total_qty': sum(sml.quantity for sml in picking.move_line_ids),
|
'total_qty': sum(sml.quantity for sml in picking.move_line_ids),
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ class ResPartner(models.Model):
|
|||||||
property_ups_carrier_account = fields.Char(
|
property_ups_carrier_account = fields.Char(
|
||||||
string="UPS Account Number",
|
string="UPS Account Number",
|
||||||
company_dependent=True,
|
company_dependent=True,
|
||||||
help="UPS carrier account number for bill-my-account shipping.",
|
help="The customer's own UPS account number. Stored on the customer and "
|
||||||
|
"recalled automatically when you ship to them with a UPS carrier that "
|
||||||
|
"has \"Bill recipient's UPS account\" enabled -- UPS then bills this "
|
||||||
|
"account for the shipping (UPS 'Bill Receiver') instead of yours.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from odoo import models, fields
|
from odoo import models, fields, api, _
|
||||||
|
|
||||||
|
|
||||||
class StockPicking(models.Model):
|
class StockPicking(models.Model):
|
||||||
@@ -8,6 +8,20 @@ class StockPicking(models.Model):
|
|||||||
string='Shipments',
|
string='Shipments',
|
||||||
compute='_compute_fusion_shipment_count',
|
compute='_compute_fusion_shipment_count',
|
||||||
)
|
)
|
||||||
|
fusion_shipping_label_attachment_id = fields.Many2one(
|
||||||
|
'ir.attachment',
|
||||||
|
string='Shipping Label',
|
||||||
|
compute='_compute_fusion_shipping_docs',
|
||||||
|
help='Most recent shipping label generated for this delivery '
|
||||||
|
'(any carrier).',
|
||||||
|
)
|
||||||
|
fusion_commercial_invoice_attachment_id = fields.Many2one(
|
||||||
|
'ir.attachment',
|
||||||
|
string='Commercial Invoice',
|
||||||
|
compute='_compute_fusion_shipping_docs',
|
||||||
|
help='Most recent commercial (customs) invoice generated for this '
|
||||||
|
'delivery (any carrier).',
|
||||||
|
)
|
||||||
|
|
||||||
def _compute_fusion_shipment_count(self):
|
def _compute_fusion_shipment_count(self):
|
||||||
Shipment = self.env['fusion.shipment']
|
Shipment = self.env['fusion.shipment']
|
||||||
@@ -16,6 +30,68 @@ class StockPicking(models.Model):
|
|||||||
[('picking_id', '=', picking.id)]
|
[('picking_id', '=', picking.id)]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends('message_ids.attachment_ids')
|
||||||
|
def _compute_fusion_shipping_docs(self):
|
||||||
|
"""Find the latest shipping label + commercial invoice that the
|
||||||
|
carrier posted to this delivery's chatter, so they can be opened
|
||||||
|
from a smart button.
|
||||||
|
|
||||||
|
Naming is carrier-agnostic (see the carriers' send_shipping):
|
||||||
|
- labels contain 'Label' (Label-, LabelUPS, LabelShipping-...)
|
||||||
|
- invoices contain 'CommercialInvoice' (UPS/Canada Post) or start
|
||||||
|
with 'ShippingDoc-' (FedEx/DHL, via _get_delivery_doc_prefix).
|
||||||
|
"""
|
||||||
|
Attachment = self.env['ir.attachment']
|
||||||
|
for picking in self:
|
||||||
|
invoice = label = Attachment
|
||||||
|
if isinstance(picking.id, int):
|
||||||
|
atts = Attachment.search(
|
||||||
|
[('res_model', '=', 'stock.picking'),
|
||||||
|
('res_id', '=', picking.id)],
|
||||||
|
order='id desc')
|
||||||
|
for att in atts:
|
||||||
|
name = att.name or ''
|
||||||
|
norm = name.lower().replace(' ', '').replace('-', '')
|
||||||
|
if 'commercialinvoice' in norm or name.startswith('ShippingDoc'):
|
||||||
|
invoice = invoice or att
|
||||||
|
elif 'label' in name.lower() and 'return' not in name.lower():
|
||||||
|
label = label or att
|
||||||
|
if invoice and label:
|
||||||
|
break
|
||||||
|
picking.fusion_commercial_invoice_attachment_id = invoice.id
|
||||||
|
picking.fusion_shipping_label_attachment_id = label.id
|
||||||
|
|
||||||
|
def _fusion_open_attachment(self, attachment):
|
||||||
|
"""Open a shipping document for the operator.
|
||||||
|
|
||||||
|
Routes PDFs through ir.attachment.action_fusion_preview (preview
|
||||||
|
dialog) when fusion_pdf_preview is installed, else opens the file
|
||||||
|
in a new tab. Mirrors fusion.shipment._action_open_attachment.
|
||||||
|
See CLAUDE.md "PDF Preview".
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not attachment:
|
||||||
|
return False
|
||||||
|
if hasattr(attachment, 'action_fusion_preview'):
|
||||||
|
return attachment.action_fusion_preview(
|
||||||
|
title=attachment.name or _('Shipping Document'),
|
||||||
|
model_name=self._name,
|
||||||
|
record_ids=self.id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': '/web/content/%s?download=false' % attachment.id,
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_fusion_shipping_label(self):
|
||||||
|
return self._fusion_open_attachment(
|
||||||
|
self.fusion_shipping_label_attachment_id)
|
||||||
|
|
||||||
|
def action_view_fusion_commercial_invoice(self):
|
||||||
|
return self._fusion_open_attachment(
|
||||||
|
self.fusion_commercial_invoice_attachment_id)
|
||||||
|
|
||||||
def action_view_fusion_shipments(self):
|
def action_view_fusion_shipments(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
shipments = self.env['fusion.shipment'].search(
|
shipments = self.env['fusion.shipment'].search(
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
<field name="ups_require_signature"/>
|
<field name="ups_require_signature"/>
|
||||||
<field name="ups_duty_payment" string="Duties paid by"
|
<field name="ups_duty_payment" string="Duties paid by"
|
||||||
required="delivery_type == 'fusion_ups_rest'"/>
|
required="delivery_type == 'fusion_ups_rest'"/>
|
||||||
|
<field name="ups_rest_documentation_type"
|
||||||
|
required="delivery_type == 'fusion_ups_rest'"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -6,12 +6,28 @@
|
|||||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//div[@name='button_box']" position="inside">
|
<xpath expr="//div[@name='button_box']" position="inside">
|
||||||
|
<field name="fusion_shipping_label_attachment_id" invisible="1"/>
|
||||||
|
<field name="fusion_commercial_invoice_attachment_id" invisible="1"/>
|
||||||
<button name="action_view_fusion_shipments" type="object"
|
<button name="action_view_fusion_shipments" type="object"
|
||||||
class="oe_stat_button" icon="fa-plane"
|
class="oe_stat_button" icon="fa-plane"
|
||||||
invisible="fusion_shipment_count == 0">
|
invisible="fusion_shipment_count == 0">
|
||||||
<field name="fusion_shipment_count" widget="statinfo"
|
<field name="fusion_shipment_count" widget="statinfo"
|
||||||
string="Shipments"/>
|
string="Shipments"/>
|
||||||
</button>
|
</button>
|
||||||
|
<button name="action_view_fusion_shipping_label" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-print"
|
||||||
|
invisible="not fusion_shipping_label_attachment_id">
|
||||||
|
<div class="o_field_widget o_stat_info">
|
||||||
|
<span class="o_stat_text">Shipping Label</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_fusion_commercial_invoice" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-file-text-o"
|
||||||
|
invisible="not fusion_commercial_invoice_attachment_id">
|
||||||
|
<div class="o_field_widget o_stat_info">
|
||||||
|
<span class="o_stat_text">Commercial Invoice</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ fi
|
|||||||
if [[ "${TESTS_OK:-0}" == "1" ]]; then
|
if [[ "${TESTS_OK:-0}" == "1" ]]; then
|
||||||
c "Compile asset bundles on clone (catches SCSS errors)"
|
c "Compile asset bundles on clone (catches SCSS errors)"
|
||||||
echo "env['ir.qweb']._get_asset_bundle('web.assets_backend').css(); env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css(); print('ASSETS_COMPILED_OK')" \
|
echo "env['ir.qweb']._get_asset_bundle('web.assets_backend').css(); env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css(); print('ASSETS_COMPILED_OK')" \
|
||||||
| dexec -i "$APP" odoo shell -d "$CLONE_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" --addons-path="$ADDONS_PATH" >>"$LOG" 2>&1 || true
|
| dexec -i "$APP" odoo shell -d "$CLONE_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" --addons-path="$ADDONS_PATH" --no-http --http-port=0 --gevent-port=0 >>"$LOG" 2>&1 || true
|
||||||
if grep -q ASSETS_COMPILED_OK "$LOG"; then
|
if grep -q ASSETS_COMPILED_OK "$LOG"; then
|
||||||
ok "Asset bundles compiled OK"
|
ok "Asset bundles compiled OK"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user