feat(sticker): 3-cell header + right-side Notes column + new field list

Restores the original ENTECH sticker layout from the operator's
screenshot reference:

Header (3 horizontal cells, divided by vertical rules):
  [Logo]  |  WO #WO-30019  |  [QR]

Body (left side = field table, right side = Notes column):
  PO #:        587854         | Notes:
  SN #:        -              | <customer-facing description>
  Customer:    ABC Manufact.  |
  Part #:      9876... Rev A  |
  Due Date:    May 17, 2026   |
  Thickness:   -              |
  Qty:         1              |

Changes from previous (stacked-left) layout:
- Header: 1-row 3-cell (Logo 28% | WO# 44% | QR 28%) replaces
  the 2-cell w/ logo+WO# stacked on left.
- Body: 2-region (66% / 34%) replaces single 7-row table.
  Notes column now spans full body height on the right.
- Fields: SN # and Thickness added; Process row removed.
- Labels: "PO (RO)" -> "PO #", "Part Number" -> "Part #".
- Notes content: switched from SO.x_fc_internal_note to the SO
  line's `name` (= customer-facing description per Sub 2 Q6).
- SN # reads _line.x_fc_serial_number (Sub 5 field).
- Thickness reads _line.x_fc_thickness with coating.thickness
  fallback (Sub 5 field, defensive 'in _fields' check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 23:33:18 -04:00
parent 2db789d7dd
commit 80d1cc5639
2 changed files with 174 additions and 177 deletions

View File

@@ -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.10.4.0', 'version': '19.0.10.5.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': [

View File

@@ -69,6 +69,19 @@
or (_so and _so.x_fc_internal_note or (_so and _so.x_fc_internal_note
and _so.x_fc_internal_note.striptags()[:100]) and _so.x_fc_internal_note.striptags()[:100])
or '-'"/> or '-'"/>
<!-- Serial number — Sub 5 added x_fc_serial_number on the SO line. -->
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_number' in _line._fields and _line.x_fc_serial_number) or '-'"/>
<!-- Thickness — Sub 5 added x_fc_thickness on the SO line; fall back
to the coating config's thickness spec if the line is bare. -->
<t t-set="_thickness" t-value="(_line and 'x_fc_thickness' in _line._fields and _line.x_fc_thickness)
or (_coating and 'thickness' in _coating._fields and _coating.thickness)
or '-'"/>
<!-- Notes content = customer-facing description. Per Sub 2 decision
Q6: SO line `name` is the customer-facing description (Odoo
standard). Falls back to product name when the line is bare. -->
<t t-set="_notes_content" t-value="(_line and _line.name)
or (_part and _part.name)
or '-'"/>
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need <!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
to fetch /report/barcode/ over the network during rendering. --> to fetch /report/barcode/ over the network during rendering. -->
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri( <t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
@@ -82,10 +95,9 @@
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
/* Boxy professional layout: thick outer border, horizontal row /* 3-cell header (Logo | WO# | QR) + 2-region body (fields left,
borders, vertical label/value divider. Absolute positioning + Notes column right). Absolute positioning + % heights/widths
% row heights force the content to fill the full page in are mandatory — wkhtmltopdf ignores vh/vw/flex. ----------- */
wkhtmltopdf (which ignores vh/vw/flex). ------------------- */
.fp-sticker { .fp-sticker {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #000; color: #000;
@@ -97,14 +109,14 @@
page-break-after: always; page-break-after: always;
page-break-inside: avoid; page-break-inside: avoid;
} }
/* ---- HEADER band — grew to 40% to fit 2x WO# + logo + bigger QR. */ /* ---- HEADER band: 3 horizontal cells, divided by vertical
rules. Logo / WO# / QR. ---- */
.fp-sticker-head-wrap { .fp-sticker-head-wrap {
position: absolute; position: absolute;
left: 0; right: 0; top: 0; left: 0; right: 0; top: 0;
height: 40%; height: 30%;
border-bottom: 2px solid #000; border-bottom: 2px solid #000;
box-sizing: border-box; box-sizing: border-box;
padding: 0;
} }
table.fp-sticker-head { table.fp-sticker-head {
width: 100%; width: 100%;
@@ -112,82 +124,70 @@
table-layout: fixed; table-layout: fixed;
border-collapse: collapse; border-collapse: collapse;
} }
table.fp-sticker-head td { padding: 0; vertical-align: middle; } col.fp-col-head-logo { width: 28%; }
col.fp-col-head-left { width: 66%; } col.fp-col-head-wo { width: 44%; }
col.fp-col-head-right { width: 34%; } col.fp-col-head-qr { width: 28%; }
td.fp-sticker-head-left { table.fp-sticker-head td {
overflow: hidden; padding: 0;
border-right: 2px solid #000; vertical-align: middle;
}
td.fp-sticker-head-right {
text-align: center; text-align: center;
vertical-align: middle;
overflow: hidden; overflow: hidden;
} }
/* Left column nested 2-row table: logo on top, WO# below. td.fp-sticker-head-logo { border-right: 2px solid #000; padding: 0 6px; }
Horizontal divider between rows mirrors body row borders. */ td.fp-sticker-head-wo { border-right: 2px solid #000; }
table.fp-sticker-head-left-stack {
width: 100%;
height: 100%;
table-layout: fixed;
border-collapse: collapse;
}
table.fp-sticker-head-left-stack tr.fp-row-logo { height: 50%; }
table.fp-sticker-head-left-stack tr.fp-row-wo { height: 50%; }
table.fp-sticker-head-left-stack td {
padding: 0 14px;
vertical-align: middle;
}
/* Logo cell + WO# cell each get explicit vertical-align so the
content sits in the middle of its half of the header band. */
table.fp-sticker-head-left-stack tr.fp-row-logo td,
table.fp-sticker-head-left-stack tr.fp-row-wo td {
vertical-align: middle;
}
table.fp-sticker-head-left-stack tr + tr td {
border-top: 1px solid #000;
}
.fp-sticker-logo { .fp-sticker-logo {
/* Logo bumped 40% (116 → 162px height, 520 → 728px width). */ max-height: 120px;
max-height: 162px; max-width: 95%;
max-width: 728px; display: inline-block;
display: block; vertical-align: middle;
} }
.fp-sticker-wo { .fp-sticker-wo {
font-size: 72pt; font-size: 44pt;
font-weight: 900; font-weight: 900;
letter-spacing: 0.2mm; letter-spacing: 0.1mm;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
margin: 0; margin: 0;
} }
/* QR wrapper crops the white quiet-zone around the QR pattern /* QR wrapper crops the white quiet-zone around the QR pattern.
so it doesn't visually float on a white square inside the Odoo's barcode generator pads ~12% of quiet-zone on each side;
cell. The PNG from Odoo's barcode generator carries a we render the image larger and offset it so the wrapper clips
~12% border (4 modules of quiet-zone) on each side; we the border out. ---- */
render the image larger than the wrapper and offset it so
the wrapper clips that border out. ---------------------- */
.fp-sticker-qr-wrap { .fp-sticker-qr-wrap {
width: 460px; width: 290px;
height: 460px; height: 290px;
display: inline-block; display: inline-block;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.fp-sticker-qr { .fp-sticker-qr {
width: 620px; width: 390px;
height: 620px; height: 390px;
position: absolute; position: absolute;
top: -80px; top: -50px;
left: -80px; left: -50px;
margin: 0; margin: 0;
display: block; display: block;
} }
/* ---- BODY band (7 rows, each 14.28% of the band) ---- */ /* ---- BODY band: left fields region + right Notes region ---- */
.fp-sticker-body-wrap { .fp-sticker-body-wrap {
position: absolute; position: absolute;
left: 0; right: 0; left: 0; right: 0;
top: 40%; bottom: 0; top: 30%; bottom: 0;
}
.fp-body-left {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 64%;
border-right: 2px solid #000;
box-sizing: border-box;
}
.fp-body-right {
position: absolute;
left: 64%; right: 0; top: 0; bottom: 0;
box-sizing: border-box;
padding: 8px 10px;
overflow: hidden;
} }
table.fp-sticker-body { table.fp-sticker-body {
width: 100%; width: 100%;
@@ -195,27 +195,20 @@
table-layout: fixed; table-layout: fixed;
border-collapse: collapse; border-collapse: collapse;
} }
/* Body rows: 6 single-line rows at 12.67% each, Notes gets 24% (~2x) so the operator has real room to write. */ table.fp-sticker-body tr { height: 14.28%; }
table.fp-sticker-body tr { height: 12.67%; }
table.fp-sticker-body tr.fp-row-notes { height: 24%; }
table.fp-sticker-body tr.fp-row-notes td.fp-sticker-value {
white-space: normal;
vertical-align: top;
padding-top: 10px;
}
table.fp-sticker-body tr + tr td { border-top: 1px solid #000; } table.fp-sticker-body tr + tr td { border-top: 1px solid #000; }
col.fp-col-label { width: 32%; } col.fp-col-label { width: 38%; }
col.fp-col-value { width: 68%; } col.fp-col-value { width: 62%; }
table.fp-sticker-body td { table.fp-sticker-body td {
vertical-align: middle; vertical-align: middle;
padding: 0 14px; padding: 0 12px;
font-size: 38pt; font-size: 22pt;
line-height: 1.1; line-height: 1.1;
} }
td.fp-sticker-label { td.fp-sticker-label {
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
border-right: 2px solid #000; border-right: 1px solid #000;
background-color: #f1f2f4; background-color: #f1f2f4;
} }
td.fp-sticker-value { td.fp-sticker-value {
@@ -224,43 +217,50 @@
white-space: nowrap; white-space: nowrap;
} }
.fp-sticker-strong { font-weight: 700; } .fp-sticker-strong { font-weight: 700; }
.fp-sticker-muted { color: #555; font-size: 28pt; } .fp-sticker-muted { color: #555; font-size: 16pt; }
/* Notes column on the right side of the body. */
.fp-notes-label {
font-weight: 700;
font-size: 22pt;
margin: 0 0 6px 0;
}
.fp-notes-content {
font-size: 16pt;
line-height: 1.3;
white-space: pre-line;
word-wrap: break-word;
overflow: hidden;
}
</style> </style>
<div class="fp-sticker"> <div class="fp-sticker">
<!-- 3-cell header: Logo | WO# | QR -->
<div class="fp-sticker-head-wrap"> <div class="fp-sticker-head-wrap">
<table class="fp-sticker-head"> <table class="fp-sticker-head">
<colgroup> <colgroup>
<col class="fp-col-head-left"/> <col class="fp-col-head-logo"/>
<col class="fp-col-head-right"/> <col class="fp-col-head-wo"/>
<col class="fp-col-head-qr"/>
</colgroup> </colgroup>
<tr> <tr>
<td class="fp-sticker-head-left"> <td class="fp-sticker-head-logo">
<!-- env.company.logo is often blank while logo_web <!-- env.company.logo is often blank while logo_web
is populated from the company partner's image. is populated from the partner's image. Fall
Fall back across both + partner.image_1920. --> back across both + partner.image_1920. -->
<t t-set="_logo" t-value="env.company.logo <t t-set="_logo" t-value="env.company.logo
or env.company.logo_web or env.company.logo_web
or env.company.partner_id.image_1920 or env.company.partner_id.image_1920
or False"/> or False"/>
<table class="fp-sticker-head-left-stack"> <img t-if="_logo"
<tr class="fp-row-logo"> class="fp-sticker-logo"
<td> t-att-src="image_data_uri(_logo)"/>
<img t-if="_logo"
class="fp-sticker-logo"
t-att-src="image_data_uri(_logo)"/>
</td>
</tr>
<tr class="fp-row-wo">
<td>
<div class="fp-sticker-wo">
WO #<span t-esc="_order_id"/>
</div>
</td>
</tr>
</table>
</td> </td>
<td class="fp-sticker-head-right"> <td class="fp-sticker-head-wo">
<div class="fp-sticker-wo">
WO #<span t-esc="_order_id"/>
</div>
</td>
<td>
<div class="fp-sticker-qr-wrap" t-if="_qr_src"> <div class="fp-sticker-qr-wrap" t-if="_qr_src">
<img class="fp-sticker-qr" <img class="fp-sticker-qr"
t-att-src="_qr_src"/> t-att-src="_qr_src"/>
@@ -270,90 +270,87 @@
</table> </table>
</div> </div>
<!-- Body: 7-row field table on the left, full-height Notes
column on the right showing the customer-facing description. -->
<div class="fp-sticker-body-wrap"> <div class="fp-sticker-body-wrap">
<table class="fp-sticker-body"> <div class="fp-body-left">
<colgroup> <table class="fp-sticker-body">
<col class="fp-col-label"/> <colgroup>
<col class="fp-col-value"/> <col class="fp-col-label"/>
</colgroup> <col class="fp-col-value"/>
<tr> </colgroup>
<td class="fp-sticker-label">PO (RO):</td> <tr>
<td class="fp-sticker-value"> <td class="fp-sticker-label">PO #:</td>
<span class="fp-sticker-strong" <td class="fp-sticker-value">
t-esc="_po_number"/> <span class="fp-sticker-strong"
<t t-if="_mo_ref"> t-esc="_po_number"/>
<span class="fp-sticker-muted"> </td>
(<span t-esc="_mo_ref"/>) </tr>
</span> <tr>
</t> <td class="fp-sticker-label">SN #:</td>
</td> <td class="fp-sticker-value">
</tr> <span t-esc="_serial_number"/>
<tr> </td>
<td class="fp-sticker-label">Customer:</td> </tr>
<td class="fp-sticker-value"> <tr>
<span t-esc="_partner_name"/> <td class="fp-sticker-label">Customer:</td>
</td> <td class="fp-sticker-value">
</tr> <span t-esc="_partner_name"/>
<tr> </td>
<td class="fp-sticker-label">Process:</td> </tr>
<td class="fp-sticker-value"> <tr>
<t t-if="_process"> <td class="fp-sticker-label">Part #:</td>
<span t-esc="_process.name"/> <td class="fp-sticker-value">
</t> <t t-if="_part">
<t t-elif="_coating"> <span class="fp-sticker-strong"
<span t-esc="_coating.name"/> t-esc="_part.part_number"/>
</t> <t t-if="_part.revision">
<t t-else="">-</t> <!-- Strip "Rev " prefix if the field
</td> value already includes it, so we
</tr> don't print "Rev Rev 1". -->
<tr> <t t-set="_rev_clean" t-value="_part.revision.strip()"/>
<td class="fp-sticker-label">Part Number:</td> <t t-if="_rev_clean.lower().startswith('rev ')">
<td class="fp-sticker-value"> <t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
<t t-if="_part"> </t>
<span class="fp-sticker-strong" <span class="fp-sticker-muted">
t-esc="_part.part_number"/> Rev <span t-esc="_rev_clean"/>
<t t-if="_part.revision"> </span>
<!-- Some parts store the revision with a </t>
"Rev " prefix already (e.g. "Rev 1"),
others store just the value ("1", "A").
Strip a leading "Rev " (case insensitive)
so we don't print "Rev Rev 1". -->
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
<t t-if="_rev_clean.lower().startswith('rev ')">
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
</t> </t>
<span class="fp-sticker-muted"> <t t-else="">-</t>
Rev <span t-esc="_rev_clean"/> </td>
</tr>
<tr>
<td class="fp-sticker-label">Due Date:</td>
<td class="fp-sticker-value">
<t t-if="_due">
<span t-esc="_due.strftime('%b %d, %Y')"/>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Thickness:</td>
<td class="fp-sticker-value">
<span t-esc="_thickness"/>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</span> </span>
</t> </td>
</t> </tr>
<t t-else="">-</t> </table>
</td> </div>
</tr> <div class="fp-body-right">
<tr> <div class="fp-notes-label">Notes:</div>
<td class="fp-sticker-label">Due Date:</td> <div class="fp-notes-content">
<td class="fp-sticker-value"> <t t-esc="_notes_content"/>
<t t-if="_due"> </div>
<span t-esc="_due.strftime('%b %d, %Y')"/> </div>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</span>
</td>
</tr>
<tr class="fp-row-notes">
<td class="fp-sticker-label">Notes:</td>
<td class="fp-sticker-value">
<t t-esc="_internal_note"/>
</td>
</tr>
</table>
</div> </div>
</div> </div>
</template> </template>