This commit is contained in:
gsinghpal
2026-05-21 21:00:10 -04:00
parent d022e529d9
commit d127e19b45
11 changed files with 699 additions and 148 deletions

View File

@@ -29,7 +29,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. | `fusion_plating_reports`, `report.paperformat` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. **For landscape custom-header reports, reuse `paperformat_fp_a4_landscape_compact`** (same shape, just rotated) — invoice landscape uses this; don't create yet-another-landscape-compact. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. (4) **XML comments cannot contain `--` (double-hyphen)** per the XML spec — `<!-- needs wkhtmltopdf --footer-html -->` fails with `XMLSyntaxError: Comment must not contain '--' (double-hyphen)`. Rewrite without the double-hyphen: `<!-- needs a wkhtmltopdf footer-html arg -->`. Bites when documenting CLI flags or option names in QWeb comments. | `fusion_plating_reports`, `report.paperformat` |
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
@@ -44,6 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition``Adresse d'expédition`, `N° de pièce``N° de pièce`, `—``â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.21.5.5',
'version': '19.0.21.5.6',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -67,6 +67,28 @@ class SaleOrder(models.Model):
'Net Terms strategies.',
)
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
# Lead Time (Phase D11) — promised production window in business
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
# so we render a proper expectation on the SO confirmation instead
# of the binary Standard/Rush we had before. Both fields default to
# 0 — `x_fc_lead_time_display` computes the right human-readable
# string (range / single value / Rush / Standard) for the PDF.
x_fc_lead_time_min_days = fields.Integer(
string='Lead Time Min (days)', tracking=True,
help='Lower bound of the promised production lead time, in '
'business days. Leave 0 if not committed.',
)
x_fc_lead_time_max_days = fields.Integer(
string='Lead Time Max (days)', tracking=True,
help='Upper bound of the promised production lead time, in '
'business days. Leave 0 if not committed.',
)
x_fc_lead_time_display = fields.Char(
string='Lead Time',
compute='_compute_lead_time_display',
help='Human-readable lead time string for the SO confirmation PDF.',
)
x_fc_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
@@ -242,6 +264,27 @@ class SaleOrder(models.Model):
currency_field='currency_id',
)
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
def _compute_lead_time_display(self):
"""Render the lead time as a human-readable string for reports.
Priority order:
- Real min/max range set → "X-Y days" or "X days"
- Range not set, rush_order on → "Rush"
- Otherwise → "Standard"
"""
for so in self:
mn = so.x_fc_lead_time_min_days or 0
mx = so.x_fc_lead_time_max_days or 0
if mn and mx and mn != mx:
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
elif mx or mn:
so.x_fc_lead_time_display = '%d days' % (mx or mn)
elif so.x_fc_rush_order:
so.x_fc_lead_time_display = 'Rush'
else:
so.x_fc_lead_time_display = 'Standard'
@api.depends('name')
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in self.

View File

@@ -201,6 +201,16 @@
</div>
<field name="x_fc_is_blanket_order"/>
<field name="x_fc_block_partial_shipments"/>
<!-- Lead Time range. Both 0 = "Standard" on
the PDF; otherwise renders "X-Y days"
(or "X days" if min==max or one is 0). -->
<label for="x_fc_lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="x_fc_lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="x_fc_lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="x_fc_lead_time_display" readonly="1"/>
</group>
</group>

View File

@@ -86,6 +86,11 @@ class FpDirectOrderWizard(models.Model):
)
internal_deadline = fields.Date(string='Internal Deadline')
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
# Lead Time — promised production window. Mirrors directly to
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
lead_time_max_days = fields.Integer(string='Lead Time Max (days)')
# ---- Order flags (Phase B) ----
is_blanket_order = fields.Boolean(
@@ -530,6 +535,8 @@ class FpDirectOrderWizard(models.Model):
'x_fc_customer_job_number': self.customer_job_number or False,
'x_fc_planned_start_date': self.planned_start_date,
'x_fc_internal_deadline': self.internal_deadline,
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
'x_fc_lead_time_max_days': self.lead_time_max_days or 0,
# commitment_date is a Datetime; customer_deadline is a Date.
# Assigning a bare Date stores midnight UTC, which renders as
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts

View File

@@ -102,6 +102,14 @@
still `customer_deadline` (wizard) →
`commitment_date` (SO). -->
<field name="customer_deadline" string="Delivery Date"/>
<!-- Lead time range (min/max business days).
Both 0 = "Standard" on the SO confirm PDF. -->
<label for="lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="is_blanket_order"/>
<field name="block_partial_shipments"/>
</group>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.11.26.16',
'version': '19.0.11.26.30',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -90,7 +90,12 @@
<t t-set="_desc"
t-value="line.fp_customer_description() if _has_helper else line.name"/>
<span t-esc="_desc" style="white-space: pre-line;"/>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<!-- Serial line is suppressed when the calling template sets
`fp_no_serial_in_desc=True` in `line.env.context`. SO
portrait does this because it shows the serial in its own
column in the part-number cell — otherwise it'd be
duplicated. Invoice / packing slip still display it here. -->
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id and not line.env.context.get('fp_no_serial_in_desc')">
<br/>
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
</t>

View File

@@ -73,6 +73,26 @@
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- Compact A4 Landscape for customer-facing landscape reports. -->
<!-- Same shape as `paperformat_fp_a4_portrait` (margin_top=8, -->
<!-- header_spacing=0) just rotated. Bind alongside the portrait -->
<!-- compact for any report that has a landscape variant. -->
<!-- ============================================================= -->
<record id="paperformat_fp_a4_landscape_compact" model="report.paperformat">
<field name="name">Fusion Plating A4 Landscape (Compact)</field>
<field name="default" eval="False"/>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">8</field>
<field name="margin_bottom">15</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- 1. Certificate of Conformance (Portal Job) — Landscape -->
<!-- ============================================================= -->
@@ -451,6 +471,11 @@
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="is_invoice_report" eval="True"/>
<!-- Same compact paperformat as the SO confirmation so the
inline custom header sits at the top of the page (not 40mm
down under Odoo's default margin). See CLAUDE.md
"wkhtmltopdf header overlap — the CoC pattern". -->
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
@@ -462,7 +487,10 @@
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
<!-- Compact landscape paperformat (same shape as the portrait
compact, just rotated) so the inline header lands at the
top with no auto-margin gap. -->
<field name="paperformat_id" ref="paperformat_fp_a4_landscape_compact"/>
</record>
<!-- ============================================================= -->

View File

@@ -14,26 +14,92 @@
<template id="report_fp_invoice_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<t t-set="form_code" t-value="'FRM-007'"/>
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id or env.company"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<h4>
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
<span t-field="doc.name"/>
</h4>
<!-- Compute helpers -->
<t t-set="title_en" t-value="
'Credit Note' if doc.move_type == 'out_refund'
else 'Vendor Bill' if doc.move_type == 'in_invoice'
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Invoice'"/>
<t t-set="title_fr" t-value="
'Note de crédit' if doc.move_type == 'out_refund'
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Facture'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
<!-- Pull FP-specific fields from the source SO when invoice_origin
names one. Falls back to an empty recordset on manual
invoices so the t-if guards in the markup stay clean. -->
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
<div class="fp-report fp-sale">
<!-- Inline 3-column header: logo+address LEFT,
NADCAP MIDDLE, title+barcode RIGHT.
Mirrors SO confirmation exactly. -->
<div class="fp-sale-header-row">
<div class="fp-sale-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
</t>
<div class="fp-sale-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<div class="fp-sale-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-sale-header-right">
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<t t-if="barcode_uri">
<div class="fp-bc-wrap" style="margin-top: 4px;">
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
<div class="page">
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
<th style="width: 50%;">
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
</th>
<th style="width: 50%;">
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
</th>
</tr>
</thead>
<tbody>
@@ -56,53 +122,131 @@
</tbody>
</table>
<!-- Invoice info -->
<!-- Row 1: Invoice Date | Due Date | Sales Rep | Customer PO # | Payment Ref -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">INVOICE DATE</th>
<th class="info-header" style="width: 25%;">DUE DATE</th>
<th class="info-header" style="width: 25%;">SOURCE</th>
<th class="info-header" style="width: 25%;">SALES REP</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Invoice Date</span>
<span class="fp-bl-fr-stk">Date de facture</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Due Date</span>
<span class="fp-bl-fr-stk">Date d'échéance</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Sales Rep</span>
<span class="fp-bl-fr-stk">Vendeur</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Customer PO #</span>
<span class="fp-bl-fr-stk">N° de B/C client</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Payment Ref</span>
<span class="fp-bl-fr-stk">Réf. paiement</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.invoice_date"/></td>
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
<td class="text-center"><span t-esc="po_number or '—'"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
</tr>
</tbody>
</table>
<!-- Lines -->
<!-- Row 2: Customer Job # | Specification | Delivery Method (pulled from source SO; hidden on manual invoices). -->
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 34%;">
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
</th>
<th class="info-header" style="width: 33%;">
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
</th>
<th class="info-header" style="width: 33%;">
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Lines (taxes column dropped; summarized in totals) -->
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 20%;">PART NUMBER</th>
<th class="text-start" style="width: 32%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 8%;">TAXES</th>
<th style="width: 12%;">AMOUNT</th>
<th class="text-start" style="width: 24%;">
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
</th>
<th class="text-start" style="width: 38%;">
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
</th>
<th style="width: 8%;">
<span class="fp-bl-en-stk">Qty</span>
<span class="fp-bl-fr-stk">Qté</span>
</th>
<th style="width: 8%;">
<span class="fp-bl-en-stk">UOM</span>
<span class="fp-bl-fr-stk">UDM</span>
</th>
<th style="width: 11%;">
<span class="fp-bl-en-stk">Unit Price</span>
<span class="fp-bl-fr-stk">Prix unitaire</span>
</th>
<th style="width: 11%;">
<span class="fp-bl-en-stk">Amount</span>
<span class="fp-bl-fr-stk">Montant</span>
</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.invoice_line_ids" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
<tr class="section-row"><td colspan="6"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
<tr class="note-row"><td colspan="6"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<!-- Three stacked lines: Part #, Name, S/N -->
<div>
<strong>Part #:</strong>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</div>
<div>
<strong>Name:</strong>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""></t>
</div>
<div>
<strong>S/N:</strong>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<span t-esc="line.x_fc_serial_id.name"/>
</t>
<t t-else=""></t>
</div>
</td>
<td>
<!-- Suppress duplicate serial in the description column -->
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">
@@ -112,9 +256,6 @@
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
@@ -127,40 +268,47 @@
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-6">
<t t-if="doc.invoice_payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-if="doc.payment_reference">
<div style="margin-top: 10px;">
<strong>Payment Reference:</strong>
<span t-field="doc.payment_reference"/>
</div>
<t t-if="doc.invoice_payment_term_id">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<t t-if="doc.invoice_payment_term_id.note">
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-else="">
<span t-field="doc.invoice_payment_term_id.name"/>
</t>
</t>
</div>
<div class="col-6" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 150px;">Subtotal</td>
<td style="min-width: 150px;">
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
</td>
<td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td>
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td>
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
<tr>
<td><strong>Amount Due</strong></td>
<td>
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
@@ -173,14 +321,14 @@
<!-- Paid stamp -->
<t t-if="doc.payment_state in ('paid', 'in_payment')">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
<span class="paid-stamp">PAID / PAYÉ</span>
</div>
</t>
<!-- Notes -->
<t t-if="doc.narration">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<strong>Notes / Remarques:</strong>
<div t-field="doc.narration"/>
</div>
</t>
@@ -198,26 +346,87 @@
<template id="report_fp_invoice_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<t t-set="form_code" t-value="'FRM-007'"/>
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id or env.company"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<h2 style="text-align: left;">
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
<span t-field="doc.name"/>
</h2>
<!-- Same compute helpers as portrait -->
<t t-set="title_en" t-value="
'Credit Note' if doc.move_type == 'out_refund'
else 'Vendor Bill' if doc.move_type == 'in_invoice'
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Invoice'"/>
<t t-set="title_fr" t-value="
'Note de crédit' if doc.move_type == 'out_refund'
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Facture'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
<div class="fp-landscape fp-sale">
<!-- 3-column inline header — same as portrait -->
<div class="fp-sale-header-row">
<div class="fp-sale-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
</t>
<div class="fp-sale-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<div class="fp-sale-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-sale-header-right">
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<t t-if="barcode_uri">
<div class="fp-bc-wrap" style="margin-top: 4px;">
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
<div class="page">
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
<th style="width: 50%;">
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
</th>
<th style="width: 50%;">
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
</th>
</tr>
</thead>
<tbody>
@@ -240,44 +449,106 @@
</tbody>
</table>
<!-- Invoice info (wide) -->
<!-- Invoice info row (wide, 6 cols on landscape) -->
<table class="bordered info-table">
<thead>
<tr>
<th>INVOICE DATE</th>
<th>DUE DATE</th>
<th>SOURCE</th>
<th>SALES REP</th>
<th>PAYMENT REF</th>
<th>CURRENCY</th>
<th>
<span class="fp-bl-en-stk">Invoice Date</span>
<span class="fp-bl-fr-stk">Date de facture</span>
</th>
<th>
<span class="fp-bl-en-stk">Due Date</span>
<span class="fp-bl-fr-stk">Date d'échéance</span>
</th>
<th>
<span class="fp-bl-en-stk">Sales Rep</span>
<span class="fp-bl-fr-stk">Vendeur</span>
</th>
<th>
<span class="fp-bl-en-stk">Customer PO #</span>
<span class="fp-bl-fr-stk">N° de B/C client</span>
</th>
<th>
<span class="fp-bl-en-stk">Payment Ref</span>
<span class="fp-bl-fr-stk">Réf. paiement</span>
</th>
<th>
<span class="fp-bl-en-stk">Currency</span>
<span class="fp-bl-fr-stk">Devise</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.invoice_date"/></td>
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '-'"/></td>
<td class="text-center"><span t-esc="po_number or ''"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
</tr>
</tbody>
</table>
<!-- Lines — hide discount column unless at least one line has a discount -->
<!-- Source SO row (wider, 3 cols, inline). Hidden on manual invoices. -->
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
<table class="bordered info-table">
<thead>
<tr>
<th>
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
</th>
<th>
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
</th>
<th>
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Lines (taxes column dropped; discount column conditional) -->
<t t-set="has_discount" t-value="any(l.discount for l in doc.invoice_line_ids)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/>
<t t-set="col_count" t-value="7 if has_discount else 6"/>
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 18%;">PART NUMBER</th>
<th class="text-start" style="width: 24%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th t-if="has_discount" style="width: 10%;">DISCOUNT</th>
<th style="width: 10%;">TAXES</th>
<th style="width: 10%;">AMOUNT</th>
<th class="text-start" style="width: 22%;">
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
</th>
<th class="text-start" style="width: 30%;">
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
</th>
<th style="width: 7%;">
<span class="fp-bl-en-stk">Qty</span>
<span class="fp-bl-fr-stk">Qté</span>
</th>
<th style="width: 7%;">
<span class="fp-bl-en-stk">UOM</span>
<span class="fp-bl-fr-stk">UDM</span>
</th>
<th style="width: 12%;">
<span class="fp-bl-en-stk">Unit Price</span>
<span class="fp-bl-fr-stk">Prix unitaire</span>
</th>
<th t-if="has_discount" style="width: 10%;">
<span class="fp-bl-en-stk">Discount</span>
<span class="fp-bl-fr-stk">Remise</span>
</th>
<th style="width: 12%;">
<span class="fp-bl-en-stk">Amount</span>
<span class="fp-bl-fr-stk">Montant</span>
</th>
</tr>
</thead>
<tbody>
@@ -291,9 +562,28 @@
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<!-- Three stacked lines: Part #, Name, S/N -->
<div>
<strong>Part #:</strong>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</div>
<div>
<strong>Name:</strong>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""></t>
</div>
<div>
<strong>S/N:</strong>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<span t-esc="line.x_fc_serial_id.name"/>
</t>
<t t-else=""></t>
</div>
</td>
<td>
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">
@@ -305,10 +595,7 @@
</td>
<td t-if="has_discount" class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
<t t-else=""></t>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
@@ -322,34 +609,47 @@
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-7">
<t t-if="doc.invoice_payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.invoice_payment_term_id.note"/>
<t t-if="doc.invoice_payment_term_id">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<t t-if="doc.invoice_payment_term_id.note">
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-else="">
<span t-field="doc.invoice_payment_term_id.name"/>
</t>
</t>
</div>
<div class="col-5" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 200px;">Subtotal</td>
<td style="min-width: 200px;">
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
</td>
<td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td>
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td>
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
<tr>
<td><strong>Amount Due</strong></td>
<td>
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
@@ -362,7 +662,7 @@
<!-- Paid stamp -->
<t t-if="doc.payment_state in ('paid', 'in_payment')">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
<span class="paid-stamp">PAID / PAYÉ</span>
</div>
</t>

View File

@@ -22,6 +22,46 @@
External_layout already places the page body at the bottom of
the reserved margin-top — don't fight that. Use a small positive
gap and shrink the title text instead. -->
<!-- Custom minimal layout — same .article wrapper that Odoo's
report pipeline expects (so UTF-8 charset handling works
correctly via the standard processing path), but with NO
auto company .header div. Includes a minimal .footer div
that ONLY carries the wkhtmltopdf page-number placeholders
(`<span class="page"/> / <span class="topage"/>`) — those
only get filled in when the .footer div is extracted into
wkhtmltopdf's footer-html stream. The .footer is otherwise
empty so no boilerplate company info shows. -->
<template id="fp_external_layout_clean">
<t t-if="not o" t-set="o" t-value="doc"/>
<t t-if="not company">
<t t-if="company_id">
<t t-set="company" t-value="company_id"/>
</t>
<t t-elif="o and 'company_id' in o and o.company_id.sudo()">
<t t-set="company" t-value="o.company_id.sudo()"/>
</t>
<t t-else="else">
<t t-set="company" t-value="res_company"/>
</t>
</t>
<div class="article o_report_layout_standard"
t-att-data-oe-model="o and o._name"
t-att-data-oe-id="o and o.id"
t-att-data-oe-lang="o and o.env.context.get('lang')">
<t t-out="0"/>
</div>
<div class="footer">
<div style="font-size: 9pt; color: #666; overflow: hidden;">
<!-- Internal form code (e.g. "FRM-006") on the left.
Each report sets `form_code` via t-set BEFORE the
t-call to this layout; reports that don't set it
leave the left side blank. -->
<span style="float: left;" t-if="form_code"><t t-esc="form_code"/></span>
<span style="float: right; white-space: nowrap; padding-left: 10px;" t-if="report_type == 'pdf'">Page <span class="page"/> / <span class="topage"/></span>
</div>
</div>
</template>
<template id="fp_sale_bilingual_styles">
<style>
/* Inline bilingual: English bold, then a faint slash, then
@@ -38,13 +78,58 @@
below in italic-grey. */
.fp-bl-en-stk { display: block; font-weight: bold; }
.fp-bl-fr-stk { display: block; font-weight: normal; font-style: italic; color: #555; font-size: 80%; margin-top: 1px; }
/* Match the CoC pattern exactly: tiny paperformat margin_top
(8mm) lets the header HTML overflow into the body area,
and this 20mm padding-top on the wrapper clears it. See
CLAUDE.md "wkhtmltopdf header overlap" — the alternative
"size margin_top to the header height" approach has zero
slack and breaks any time the header HTML grows. */
.fp-report.fp-sale { padding-top: 20mm; }
/* This template uses fp_external_layout_clean (sibling
template in this file) instead of web.external_layout.
That gives us the `.article` wrapper Odoo's report
renderer needs for proper UTF-8 dispatch, WITHOUT the
`.header` / `.footer` divs that wkhtmltopdf would
extract into page-margin streams. So the body owns the
entire visible header (logo + address LEFT, title +
barcode RIGHT) and no auto company band shows up. See
CLAUDE.md "Custom-header reports need .article wrapper". */
.fp-report.fp-sale { padding-top: 0; }
/* Custom inline header: 2-column flex-via-floats. Left has
the company logo + address + phone + URL; right has the
document title (bilingual stack) + Code128 barcode. */
.fp-sale-header-row { overflow: hidden; margin-bottom: 14px; padding-bottom: 6px; }
.fp-sale-header-left { float: left; width: 38%; }
/* Middle column: NADCAP accreditation logo (pulled from
company settings) above a small "25 Years in Business"
medallion. Conditional on company.x_fc_nadcap_active so
non-Nadcap shops only see the badge. */
.fp-sale-header-mid { float: left; width: 24%; text-align: center; padding-top: 4px; }
.fp-nadcap-logo { max-height: 45px; max-width: 115px; display: inline-block; }
/* 25-Years-in-Business medallion: tasteful gold-on-cream
bordered badge. Picks a dark muted gold (#8a6d2c) so it
reads as "anniversary keepsake" rather than gaudy. */
.fp-25-badge {
display: inline-block;
border: 1.5px solid #c8a55b;
background-color: #faf5e8;
padding: 5px 12px;
border-radius: 4px;
margin-top: 6px;
text-align: center;
line-height: 1.05;
}
.fp-25-badge .fp-25-num { font-size: 22pt; font-weight: bold; color: #8a6d2c; }
.fp-25-badge .fp-25-lbl { font-size: 7pt; font-weight: bold; color: #8a6d2c; letter-spacing: 1.2px; margin-top: 1px; }
.fp-25-badge .fp-25-sub { font-size: 6.5pt; color: #8a6d2c; font-style: italic; margin-top: 1px; }
/* Right column: title (English bold + French italic) + barcode
+ SO number all centered as a stacked block. Width reduced
to 38% to share row with the new middle NADCAP+25Years cell. */
.fp-sale-header-right { float: right; width: 38%; text-align: center; }
.fp-sale-logo { max-height: 50px; max-width: 280px; display: block; margin-bottom: 4px; }
.fp-sale-company-addr { font-size: 8.5pt; color: #222; line-height: 1.35; }
.fp-sale-company-addr div { margin: 0; }
.fp-sale-company-addr a { color: #2e6da4; text-decoration: none; }
/* Inline footer line — phone | email | website | tax id.
One-time render at the bottom of page 1 (multi-page SO
reports are rare; if we ever need it, switch to
wkhtmltopdf --footer-html). */
.fp-sale-customfooter { text-align: center; font-size: 8pt; color: #666; margin-top: 24px; padding-top: 8px; border-top: 1px solid #ccc; }
/* Title bar uses float-based div layout, NOT an HTML table —
the global ".fp-report table" rule was applying borders
to every nested table even with "border: 0 !important",
@@ -65,48 +150,83 @@
.fp-sale-barcode { float: right; margin-left: 12px; }
.fp-bc-wrap { display: inline-block; text-align: center; }
.fp-bc-wrap img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
.fp-bc-wrap .fp-bc-label { font-size: 10pt; color: #333; margin-top: 6px; letter-spacing: 0.5px; }
.fp-bc-wrap .fp-bc-label { font-size: 16pt; font-weight: bold; color: #000; margin-top: 6px; letter-spacing: 1.5px; }
</style>
</template>
<template id="report_fp_sale_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<!-- Internal form code rendered on the footer left side
by fp_external_layout_clean (see that template). -->
<t t-set="form_code" t-value="'FRM-006'"/>
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id or env.company"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<!-- Compute helpers -->
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
<!-- Compute helpers -->
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<div class="fp-report fp-sale">
<div class="page">
<!-- Title bar: stacked English/French title
on the left, Code128 barcode floated
right. NO HTML table — see CLAUDE.md
"wkhtmltopdf header overlap" §2 for why
a table here leaks borders. -->
<div class="fp-sale-titlebar">
<t t-if="barcode_uri">
<div class="fp-sale-barcode">
<div class="fp-bc-wrap">
<img t-att-src="barcode_uri" alt="Order Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</div>
</t>
<span class="fp-sale-title-en">
<t t-esc="title_en"/><span class="fp-sale-title-num"># <span t-field="doc.name"/></span>
</span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<div class="fp-report fp-sale">
<!-- Inline header (drops web.external_layout for this
report — see CSS comment for context). Left: logo
+ address + tel/fax + URL. Right: bilingual title
+ Code128 barcode of the order number. -->
<div class="fp-sale-header-row">
<div class="fp-sale-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
</t>
<div class="fp-sale-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<!-- Middle column: NADCAP logo only. Base64-inlined
from `company.x_fc_nadcap_logo` so wkhtmltopdf
doesn't have to fetch over HTTP (network calls
fail on entech). -->
<div class="fp-sale-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-sale-header-right">
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<t t-if="barcode_uri">
<div class="fp-bc-wrap" style="margin-top: 4px;">
<img t-att-src="barcode_uri" alt="Order Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
<div class="page">
<!-- Billing / Shipping (wide cells — inline) -->
<table class="bordered">
@@ -174,10 +294,18 @@
<td class="text-center"><span t-field="doc.user_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_po_number or '—'"/></td>
<td class="text-center">
<t t-if="doc.x_fc_rush_order">
<span class="status-warning">Rush / Urgent</span>
<!-- Lead Time renders from the
computed display string on
sale.order. Rush stays
highlighted; everything
else (range / single value
/ Standard) is plain text. -->
<t t-if="doc.x_fc_lead_time_display == 'Rush'">
<span class="status-warning">Rush</span>
</t>
<t t-else="">
<span t-esc="doc.x_fc_lead_time_display"/>
</t>
<t t-else="">Standard</t>
</td>
</tr>
</tbody>
@@ -267,13 +395,34 @@
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span> - </span>
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<!-- Three stacked lines:
1. Part Number (catalog part_number + revision)
2. Name (catalog name, falls back to em-dash)
3. Serial Number (m2m serials joined, or em-dash) -->
<div>
<strong>Part #:</strong>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</div>
<div>
<strong>Name:</strong>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""></t>
</div>
<div>
<strong>S/N:</strong>
<t t-if="line.x_fc_serial_ids">
<span t-esc="', '.join(line.x_fc_serial_ids.mapped('name'))"/>
</t>
<t t-else=""></t>
</div>
</td>
<td>
<!-- Rebind `line` with fp_no_serial_in_desc=True so the
shared description macro skips its Serial line — we
already render S/N in the part-number cell above. -->
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">