changes
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

This commit is contained in:
gsinghpal
2026-05-17 03:20:33 -04:00
parent f8586611c9
commit d3c5c25865
30 changed files with 712 additions and 183 deletions

View File

@@ -267,10 +267,10 @@ Each phase bumps the patch version (19.0.3.0.0 → .1.0 → .2.0 → .3.0 → .4
These are flagged as assumptions to confirm against the live model, not blockers: These are flagged as assumptions to confirm against the live model, not blockers:
1. **Stage timestamp sources**the 5 stages on `fp.portal.job` may not all have explicit timestamp fields. Confirm: do we need to add fields, or pull from the underlying `fp.job` / chatter? If fields are missing, add them as `Datetime` on `fp.portal.job` with `compute=` from the underlying records. 1. **Stage timestamp sources**RESOLVED 2026-05-17 Phase 3 investigation: `fp.portal.job` is intentionally decoupled from `fp.job` (no `job_id` link). Existing Date fields cover received/shipped only; the 3 middle stages had no timestamps. Decision: **Option B** — added per-stage `Datetime` fields (`received_at`, `in_progress_started_at`, `qc_started_at`, `ready_to_ship_at`, `shipped_at`) with a `write()` override that snapshots `fields.Datetime.now()` on state change. Idempotent — won't overwrite if already set.
2. **Operator name surfacing** — customers seeing operator names is a privacy / policy question. Default in the spec is to show first-initial + last-name (e.g., "D. Mendez"). Confirm with EN Plating before shipping. 2. **Operator name surfacing** — customers seeing operator names is a privacy / policy question. Default in the spec is to show first-initial + last-name (e.g., "D. Mendez"). Confirm with EN Plating before shipping.
3. **Stage-notes copy** — the example notes in the mockup ("Tank 4 · 45 min cycle · Day 3 of 7") are made up. Confirm what info is reasonable to share with the customer per stage. 3. **Stage-notes copy** — the example notes in the mockup ("Tank 4 · 45 min cycle · Day 3 of 7") are made up. Confirm what info is reasonable to share with the customer per stage.
4. **Document linking edge cases**what shows when there are 0 documents in a category? Spec assumes a "Will appear when…" placeholder card per the approved mockup. Verify EN Plating doesn't want the placeholder hidden entirely when empty. 4. **Document linking edge cases**RESOLVED 2026-05-17: `fp.portal.job` has no link to `sale.order` / `quote_request` / `part_catalog`, so V1 cannot reach PO / Drawing / Spec documents through the portal job alone. Decision: **Option C for V1** — surface only the directly-attached fields (`coc_attachment_id`, `packing_list_attachment_id`); render the From-You / Specifications / Quality (when CoC missing) / Shipping (when packing missing) groups as placeholder rows ("Will appear when ..." messaging per the approved mockup). V2 (separate change) will add `sale_order_id` Many2one to `fp.portal.job` and pull PO/drawing/spec docs via that link.
5. **Dark mode** — explicitly deferred. If a customer logs in with `color_scheme=dark` set, what should they see? Default Bootstrap dark fallback is ugly. Suggest: force the portal to `color_scheme=light` for `share=True` users, or add a `prefers-color-scheme: light` meta tag. Document the choice during Phase 1. 5. **Dark mode** — explicitly deferred. If a customer logs in with `color_scheme=dark` set, what should they see? Default Bootstrap dark fallback is ugly. Suggest: force the portal to `color_scheme=light` for `share=True` users, or add a `prefers-color-scheme: light` meta tag. Document the choice during Phase 1.
--- ---

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.20.0.0', 'version': '19.0.20.1.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -33,6 +33,11 @@ class FpProcessNode(models.Model):
_parent_name = 'parent_id' _parent_name = 'parent_id'
_order = 'parent_path, sequence, id' _order = 'parent_path, sequence, id'
_rec_name = 'display_name' _rec_name = 'display_name'
# Search by both name and code in m2o autocomplete pickers (e.g. the
# Process / Recipe field on the direct-order wizard line). Without
# this, typing the recipe code (e.g. "ENP-STEEL-BASIC") didn't match
# because display_name composes from `name` alone for recipe roots.
_rec_names_search = ['name', 'code']
# ---- Identity & hierarchy ------------------------------------------------ # ---- Identity & hierarchy ------------------------------------------------

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.6.0.0', 'version': '19.0.6.1.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': """

View File

@@ -15,9 +15,10 @@ class ResConfigSettings(models.TransientModel):
x_fc_owner_user_id = fields.Many2one( x_fc_owner_user_id = fields.Many2one(
related='company_id.x_fc_owner_user_id', readonly=False, related='company_id.x_fc_owner_user_id', readonly=False,
) )
x_fc_coc_signature_override = fields.Binary( # x_fc_coc_signature_override was retired 2026-05-17 — cert
related='company_id.x_fc_coc_signature_override', readonly=False, # signatures now come from the certifier user's Plating Signature
) # only. Field stays on res.company (no migration) but is no longer
# exposed in settings.
x_fc_nadcap_logo = fields.Binary( x_fc_nadcap_logo = fields.Binary(
related='company_id.x_fc_nadcap_logo', readonly=False, related='company_id.x_fc_nadcap_logo', readonly=False,
) )

View File

@@ -22,12 +22,14 @@
<field name="x_fc_owner_user_id" <field name="x_fc_owner_user_id"
options="{'no_create': True, 'no_open': True}"/> options="{'no_create': True, 'no_open': True}"/>
</setting> </setting>
<setting id="fp_coc_sig_override" <!-- Removed 2026-05-17: the "Signature Override Image"
string="Signature Override Image" setting was retired. Cert signatures now come
help="Upload a scanned signature here to override the owner user's employee signature (useful if they don't have an HR record)."> exclusively from the certifier user's Plating
<field name="x_fc_coc_signature_override" Signature (Preferences → My Profile →
widget="image" class="oe_avatar"/> x_fc_signature_image). The DB column on
</setting> res.company stays in place for now (no
migration) but is no longer read by any
report. -->
</block> </block>
<block title="Accreditation Logos" <block title="Accreditation Logos"

View File

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

View File

@@ -62,7 +62,7 @@ class FpDirectOrderLine(models.Model):
process_variant_id = fields.Many2one( process_variant_id = fields.Many2one(
'fusion.plating.process.node', 'fusion.plating.process.node',
string='Process Variant', string='Process Variant',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]", domain="[('id', 'in', recipe_choice_ids)]",
ondelete='set null', ondelete='set null',
help='Pick any recipe — the part\'s own variant, another part\'s ' help='Pick any recipe — the part\'s own variant, another part\'s '
'recipe, or a template from the library. Cross-part picks ' 'recipe, or a template from the library. Cross-part picks '
@@ -70,6 +70,44 @@ class FpDirectOrderLine(models.Model):
'scoped. Use the Customize button to open the Process ' 'scoped. Use the Customize button to open the Process '
'Composer for the chosen variant.', 'Composer for the chosen variant.',
) )
# Pre-computed pick-list backing the process_variant_id m2o.
# Scope: parent recipes (templates) + this part's own variants +
# any recipe previously used on this customer's SO lines. Replaces
# the previous wide-open domain that exposed every recipe in the
# system. Recomputed on (part, customer) change.
recipe_choice_ids = fields.Many2many(
'fusion.plating.process.node',
compute='_compute_recipe_choice_ids',
string='Allowed Recipes (computed)',
)
@api.depends('part_catalog_id', 'wizard_id.partner_id')
def _compute_recipe_choice_ids(self):
Node = self.env['fusion.plating.process.node']
SOL = self.env['sale.order.line']
for rec in self:
ids = set()
# 1) Templates — the "parent recipes" the operator sees first.
templates = Node.search([
('parent_id', '=', False),
('node_type', '=', 'recipe'),
('is_template', '=', True),
])
ids.update(templates.ids)
# 2) This part's own variants (scoped recipes already cloned
# onto the part).
if rec.part_catalog_id:
ids.update(rec.part_catalog_id.process_variant_ids.ids)
# 3) Recipes previously used on this customer's SO lines.
# Capped to avoid sweeping every history row on big customers.
if rec.wizard_id and rec.wizard_id.partner_id:
used = SOL.search([
('order_id.partner_id', '=', rec.wizard_id.partner_id.id),
('x_fc_process_variant_id', '!=', False),
], order='create_date desc', limit=500
).mapped('x_fc_process_variant_id')
ids.update(used.ids)
rec.recipe_choice_ids = [(6, 0, list(ids))]
save_as_default_process = fields.Boolean( save_as_default_process = fields.Boolean(
string='Set as Part Default', string='Set as Part Default',
help='When ticked, the chosen process variant becomes this part\'s ' help='When ticked, the chosen process variant becomes this part\'s '
@@ -152,13 +190,33 @@ class FpDirectOrderLine(models.Model):
# fusion_plating_quality (where customer_spec_id field lives). # fusion_plating_quality (where customer_spec_id field lives).
has_default_spec = bool(getattr( has_default_spec = bool(getattr(
part, 'x_fc_default_customer_spec_id', False)) part, 'x_fc_default_customer_spec_id', False))
# New-part auto-suggest: if no default spec exists, this is # The quality inherit's _onchange_part_default_spec ALSO
# likely a first-time use of the part. Auto-tick the # falls back to the most recent SO line for (part, customer)
# push_to_defaults toggle so whatever Sarah picks becomes # when the part default is empty. If that lookup will find
# the saved default — surface a warning popup so she knows. # a hit, this is NOT a first-time use from the operator's
# `is_one_off` always wins (operator opted out of catalog # perspective — the spec will silently pre-fill from history.
# persistence), so don't auto-tick in that case. # Suppress the warning in that case so we don't pop a
# misleading "no saved specification" alert right when the
# spec actually does auto-fill.
has_history_spec = False
partner = rec.wizard_id and rec.wizard_id.partner_id
SOL = self.env['sale.order.line']
if (partner
and 'x_fc_customer_spec_id' in SOL._fields):
has_history_spec = bool(SOL.search_count([
('x_fc_part_catalog_id', '=', part.id),
('order_id.partner_id', '=', partner.id),
('x_fc_customer_spec_id', '!=', False),
]))
# New-part auto-suggest: if no default spec exists AND no
# SO-line history exists, this is genuinely a first-time use
# of the part. Auto-tick the push_to_defaults toggle so
# whatever Sarah picks becomes the saved default — surface a
# warning popup so she knows. `is_one_off` always wins
# (operator opted out of catalog persistence), so don't
# auto-tick in that case.
if (not has_default_spec if (not has_default_spec
and not has_history_spec
and not rec.is_one_off and not rec.is_one_off
and not rec.push_to_defaults): and not rec.push_to_defaults):
rec.push_to_defaults = True rec.push_to_defaults = True
@@ -512,17 +570,61 @@ class FpDirectOrderLine(models.Model):
def _onchange_part_defaults(self): def _onchange_part_defaults(self):
"""Seed defaults when a part is picked. """Seed defaults when a part is picked.
Order of precedence for "remember last entered" fields
(process_variant_id, unit_price, tax_ids):
1. What the operator already typed on this line — never clobber
2. Most recent SO line for (part_catalog_id, partner) — the
"last entered" carry-over so repeat orders feel sticky
3. Fall back to product / part defaults
Spec auto-fill is handled by an inherit in fusion_plating_quality Spec auto-fill is handled by an inherit in fusion_plating_quality
(the customer_spec_id field lives there). (the customer_spec_id field lives there).
""" """
if not self.part_catalog_id: if not self.part_catalog_id:
return return
# Seed default taxes from the FP-SERVICE product, fiscal-position self._fp_seed_from_last_so_line()
# mapped from the customer. Only fills when the user hasn't set # Fall back to product taxes if no prior SO line found and the
# taxes manually. # operator hasn't set taxes manually.
if not self.tax_ids: if not self.tax_ids:
self._seed_default_taxes() self._seed_default_taxes()
def _fp_seed_from_last_so_line(self):
"""Carry the (process variant, unit price, taxes) from the most
recent SO line for this (part, customer) onto the wizard line.
Skips any field the operator has already filled. Quietly no-ops
when no prior SO line exists, when the part has no partner yet,
or when the line is for a one-off part (no expectation of
history). Called from the part_catalog_id onchange.
"""
self.ensure_one()
if not self.part_catalog_id or not self.wizard_id:
return
partner = self.wizard_id.partner_id
if not partner:
return
recent = self.env['sale.order.line'].search([
('x_fc_part_catalog_id', '=', self.part_catalog_id.id),
('order_id.partner_id', '=', partner.id),
], order='create_date desc', limit=1)
if not recent:
return
# Process variant — only if the line doesn't already have a pick.
# The part's default still applies as a fallback in
# _onchange_part_clears_variant above; this beats it when the
# customer's last SO had a specific variant.
if not self.process_variant_id and recent.x_fc_process_variant_id:
self.process_variant_id = recent.x_fc_process_variant_id
# Unit price — only when blank/zero. Avoids overwriting a
# quote-driven or hand-typed price.
if not self.unit_price and recent.price_unit:
self.unit_price = recent.price_unit
# Taxes — only when blank. The downstream
# _seed_default_taxes() fallback handles the no-prior-line case.
# NB: SO line field is `tax_ids` (Odoo 19 renamed from tax_id).
if not self.tax_ids and recent.tax_ids:
self.tax_ids = [(6, 0, recent.tax_ids.ids)]
def _seed_default_taxes(self): def _seed_default_taxes(self):
"""Pick taxes from the FP-SERVICE product, mapped through the """Pick taxes from the FP-SERVICE product, mapped through the
customer's fiscal position when one is set.""" customer's fiscal position when one is set."""
@@ -690,9 +792,11 @@ class FpDirectOrderLine(models.Model):
return clone return clone
def _fp_apply_recipe_polish(self): def _fp_apply_recipe_polish(self):
"""Post-write step: auto-clone any cross-part recipe pick and """Post-write step: auto-clone any cross-part recipe pick, set
honour the Save-as-Default toggle. Called from create() and the freshly-cloned recipe as the part's default IF this is the
write().""" part's very first variant, and honour the manual Save-as-Default
toggle for repeat orders that explicitly want to flip the
default. Called from create() and write()."""
for line in self: for line in self:
if not line.part_catalog_id or not line.process_variant_id: if not line.part_catalog_id or not line.process_variant_id:
continue continue
@@ -703,7 +807,19 @@ class FpDirectOrderLine(models.Model):
if clone and clone.id != recipe.id: if clone and clone.id != recipe.id:
line.process_variant_id = clone.id line.process_variant_id = clone.id
recipe = clone recipe = clone
if line.save_as_default_process and recipe.part_catalog_id: # Auto-default rule: if the part has no other variants
# besides the one we just attached, this recipe becomes its
# default automatically. Estimators don't have to remember
# to tick "Save as Default" for first-time parts.
other_variants = (
line.part_catalog_id.process_variant_ids - recipe
)
should_auto_default = (
bool(recipe.part_catalog_id)
and not other_variants
)
if (line.save_as_default_process or should_auto_default) \
and recipe.part_catalog_id:
line.part_catalog_id.action_set_default_variant(recipe.id) line.part_catalog_id.action_set_default_variant(recipe.id)
def action_customize_process(self): def action_customize_process(self):

View File

@@ -552,13 +552,19 @@ class FpDirectOrderWizard(models.Model):
resolved_parts[line.id] = part resolved_parts[line.id] = part
# Build the line header. Specification is optional; when # Build the line header. Specification is optional; when
# missing, drop it from the header rather than printing # missing, drop it from the header rather than printing
# "False - PartName Rev A". # "False - PartName Rev A". Same defensive treatment for
# part identifier and revision — `%s` on a False/NULL field
# used to print the literal string "False".
spec = getattr(line, 'customer_spec_id', False) spec = getattr(line, 'customer_spec_id', False)
spec_label = (spec.display_name if spec else '') or _('No spec') spec_label = (spec.display_name if spec else '') or _('No spec')
header = '%s - %s Rev %s (x%d)' % ( # Prefer part_number (set on every saved part); fall back to
# name; skip the segment entirely if both are missing.
part_label = part.part_number or part.name or ''
rev_suffix = (' Rev %s' % part.revision) if part.revision else ''
header = '%s - %s%s (x%d)' % (
spec_label, spec_label,
part.name, part_label or _('Unspecified part'),
part.revision, rev_suffix,
line.quantity, line.quantity,
) )
extended = (line.line_description or '').strip() extended = (line.line_description or '').strip()

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 — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.2.0', 'version': '19.0.10.8.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.',

View File

@@ -25,6 +25,7 @@
<field name="header_line" eval="False"/> <field name="header_line" eval="False"/>
<field name="header_spacing">0</field> <field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/> <field name="disable_shrinking" eval="True"/>
<!-- dpi=300 calibrated — see CLAUDE.md rule 14, 600 broke layout. -->
<field name="dpi">300</field> <field name="dpi">300</field>
</record> </record>
@@ -47,6 +48,14 @@
reads (so `_so or ...` doesn't NameError). We reads (so `_so or ...` doesn't NameError). We
then override the ones we have data for. --> then override the ones we have data for. -->
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<!-- Multi-line trigger: parent SO has 2+ part-bearing lines.
Even though this job is for a single specific part (jobs
are grouped by recipe+part+coating+thickness+SN), the
consolidated PO sticker is the requested behaviour. -->
<t t-set="_so_part_lines" t-value="job.sale_order_id
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
or job.env['sale.order.line']"/>
<t t-set="_multi_line" t-value="len(_so_part_lines) &gt;= 2"/>
<!-- Pre-resolve the variables the shared inner template <!-- Pre-resolve the variables the shared inner template
expects, sourcing them from fp.job's native fields. --> expects, sourcing them from fp.job's native fields. -->
<t t-set="_order_id" t-value="job.name"/> <t t-set="_order_id" t-value="job.name"/>
@@ -54,13 +63,13 @@
<t t-set="_scan_path" t-value="'/fp/job/'"/> <t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/> <t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/> <t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/> <t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/> <t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/> <t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
<t t-set="_process" t-value="job.recipe_id or False"/> <t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
<t t-set="_due" t-value="job.date_deadline or False"/> <t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
<t t-set="_qty" t-value="job.qty"/> <t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/> <t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/> <t t-set="_partner_name" t-value="job.partner_id.name"/>
<!-- The fp.job's own name (WH/JOB/00033) is already <!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress printed in the header as "WO #...", so suppress
@@ -91,25 +100,31 @@
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-foreach="docs" t-as="job"> <t t-foreach="docs" t-as="job">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_so_part_lines" t-value="job.sale_order_id
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
or job.env['sale.order.line']"/>
<t t-set="_multi_line" t-value="len(_so_part_lines) &gt;= 2"/>
<t t-set="_order_id" t-value="job.name"/> <t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/> <t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/> <t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/> <t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/> <t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/> <t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/> <t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/> <t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
<t t-set="_process" t-value="job.recipe_id or False"/> <t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
<t t-set="_due" t-value="job.date_deadline or False"/> <t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
<t t-set="_qty" t-value="job.qty"/> <t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/> <t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/> <t t-set="_partner_name" t-value="job.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/> <t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description from <!-- Internal override: read x_fc_internal_description from
the first linked SO line. --> the first linked SO line. Multi-line PO blanks it
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1] since each line has its own description. -->
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields <t t-set="_notes_content" t-value="'-' if _multi_line else
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/> ((job.sale_order_line_ids[:1]
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-')"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t> </t>
</t> </t>

View File

@@ -114,7 +114,7 @@
<div class="page fp-wo-detail"> <div class="page fp-wo-detail">
<style> <style>
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; } .fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 14px 0; font-weight: bold; color: #1a4d80; } .fp-wo-detail h1 { text-align: center; font-size: 22pt; margin: 0 0 14px 0; font-weight: bold; color: #2e2e2e; }
.fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; } .fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; } .fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
.fp-wo-detail table.bordered, .fp-wo-detail table.bordered,
@@ -134,8 +134,8 @@
keeps captions glued to their image. */ keeps captions glued to their image. */
.fp-wo-detail .fp-photo-section { margin-top: 18px; } .fp-wo-detail .fp-photo-section { margin-top: 18px; }
.fp-wo-detail .fp-photo-section h2 { .fp-wo-detail .fp-photo-section h2 {
font-size: 13pt; font-weight: bold; color: #1a4d80; font-size: 13pt; font-weight: bold; color: #2e2e2e;
margin: 0 0 8px 0; border-bottom: 2px solid #1a4d80; margin: 0 0 8px 0; border-bottom: 2px solid #c1c1c1;
padding-bottom: 3px; padding-bottom: 3px;
} }
.fp-wo-detail .fp-photo-grid { .fp-wo-detail .fp-photo-grid {
@@ -162,7 +162,7 @@
font-size: 8pt; color: #444; line-height: 1.25; font-size: 8pt; color: #444; line-height: 1.25;
} }
.fp-wo-detail .fp-photo-ref { .fp-wo-detail .fp-photo-ref {
font-size: 8pt; color: #1a4d80; font-style: italic; font-size: 8pt; color: #4e4e4e; font-style: italic;
white-space: nowrap; white-space: nowrap;
} }
/* Inline signature image inside the step /* Inline signature image inside the step
@@ -243,6 +243,65 @@
</tr> </tr>
</table> </table>
<!-- ===== Contract Review (QA-005) block =====
Surfaces the part's QA-005 audit trail on
every WO Detail print. Per Rule 4 of the
contract-review flow, on repeat orders the
contract-review WO step is auto-completed
at job creation using the reviewer's
identity + date from the existing review;
this block makes that audit visible to
customers and inspectors. Hidden when no
review exists (e.g. customer doesn't
require contract review). -->
<t t-set="review"
t-value="('part_catalog_id' in job._fields and job.part_catalog_id
and 'x_fc_contract_review_id' in job.part_catalog_id._fields
and job.part_catalog_id.x_fc_contract_review_id) or False"/>
<t t-if="review">
<t t-set="_signer"
t-value="review.s30_signed_by or review.s20_signed_by"/>
<t t-set="_signed_dt"
t-value="review.s30_signed_date or review.s20_signed_date"/>
<t t-set="_initials_src"
t-value="(_signer and _signer.name) or ''"/>
<t t-set="_initials"
t-value="''.join([w[:1].upper() for w in _initials_src.split()[:3]])"/>
<table class="bordered" style="margin-top: 8px;">
<tr>
<th colspan="4" style="font-size: 10pt;">Contract Review (QA-005)</th>
</tr>
<tr>
<th style="width: 28%;">Status</th>
<th style="width: 30%;">Reviewer</th>
<th style="width: 14%;">Initials</th>
<th style="width: 28%;">Date Reviewed</th>
</tr>
<tr>
<td>
<t t-if="review.state == 'complete'">
<span style="font-weight: bold; color: #2e2e2e;">QA-005 Approved</span>
</t>
<t t-else="">
<span style="color: #aa0000;">Pending — <span t-esc="dict(review._fields['state'].selection).get(review.state, review.state)"/></span>
</t>
</td>
<td>
<span t-esc="(_signer and _signer.name) or '—'"/>
</td>
<td>
<span style="font-weight: bold;" t-esc="_initials or '—'"/>
</td>
<td>
<t t-if="_signed_dt">
<span t-esc="job.fp_format_local(_signed_dt, '%Y-%m-%d')"/>
</t>
<t t-else=""></t>
</td>
</tr>
</table>
</t>
<div class="fp-spec">Specification(s): <div class="fp-spec">Specification(s):
<span style="font-weight: normal;" <span style="font-weight: normal;"
t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/> t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/>
@@ -481,21 +540,30 @@
<div style="page-break-before: always;"/> <div style="page-break-before: always;"/>
<div style="height: 8mm;"/> <div style="height: 8mm;"/>
<!-- Certifier = the job's plating manager. Pulls <!-- Certifier = the company's QA Manager, set in
their Plating Signature (`x_fc_signature_image`) Settings → Fusion Plating → Contract Review.
from Preferences → My Profile. Falls back to Falls back to the job's plating manager, then
the company owner's signature, then to the the company owner, then the settings signature
settings override only if no user has one. --> override. Pulls the certifier's Plating
<t t-set="certifier_user" t-value="job.manager_id or (('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id) or False)"/> Signature (`x_fc_signature_image`) from
Preferences → My Profile.
Resolution priority added 2026-05-17 per
ops request — was auto-defaulting to the
current user (whoever the job manager
happened to be) which signed every cert as
the wrong person. -->
<t t-set="_qa_managers" t-value="('x_fc_qa_manager_user_ids' in company._fields and company.x_fc_qa_manager_user_ids) or False"/>
<t t-set="certifier_user" t-value="(_qa_managers and _qa_managers[:1])
or job.manager_id
or (('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id) or False)"/>
<t t-set="signature_img" t-value="False"/> <t t-set="signature_img" t-value="False"/>
<t t-if="certifier_user and 'x_fc_signature_image' in certifier_user._fields and certifier_user.x_fc_signature_image"> <t t-if="certifier_user and 'x_fc_signature_image' in certifier_user._fields and certifier_user.x_fc_signature_image">
<t t-set="signature_img" t-value="certifier_user.x_fc_signature_image"/> <t t-set="signature_img" t-value="certifier_user.x_fc_signature_image"/>
</t> </t>
<!-- Final fallback: company-level override for sites <!-- Signature Override Image fallback retired
whose certifier hasn't uploaded their signature yet. --> 2026-05-17. If no certifier user has uploaded
<t t-if="not signature_img and 'x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override"> their Plating Signature, the cert prints
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override"/> without a signature image (Name still shows). -->
</t>
<t t-set="signer_name" t-value="(certifier_user and certifier_user.name) or ''"/> <t t-set="signer_name" t-value="(certifier_user and certifier_user.name) or ''"/>
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/> <t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Adds a leading "Part #" column to the customer portal's Sales Order
products table. Reads sale.order.line.x_fc_part_catalog_id.part_number
(defined in fusion_plating_configurator). The existing second column
keeps line.name — the customer-facing description.
Layout after this inherit:
| Part # | Description | Quantity | Unit Price | [Disc] | [Taxes] | Amount |
-->
<odoo>
<template id="sale_order_portal_content_fp_part_column"
inherit_id="sale.sale_order_portal_content">
<!-- ============================================================ -->
<!-- Header: Part # before "Products" -->
<!-- ============================================================ -->
<xpath expr="//th[@id='product_name_header']" position="before">
<th class="text-start" id="product_part_header">Part #</th>
</xpath>
<!-- ============================================================ -->
<!-- Product rows: Part # cell before the description cell. -->
<!-- Reads from x_fc_part_catalog_id; service / freight / display -->
<!-- lines with no part catalog show an empty cell. -->
<!-- ============================================================ -->
<xpath expr="//td[@name='td_product_name']" position="before">
<td name="td_product_part" t-att-class="padding_class">
<span t-if="'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id"
t-out="line.x_fc_part_catalog_id.part_number"/>
</td>
</xpath>
<!-- Combo header rows: empty Part # cell (the part lives on the
child lines, not the combo header). -->
<xpath expr="//td[@name='td_combo_name']" position="before">
<td name="td_combo_part" t-att-class="padding_class"/>
</xpath>
<!-- Collapsed section group rows: empty Part # cell. -->
<xpath expr="//td[@name='td_section_group_name']" position="before">
<td name="td_section_group_part"/>
</xpath>
<!-- ============================================================ -->
<!-- Section / subsection rows: extend the name-cell colspan by -->
<!-- one so the new Part # column is absorbed into the section -->
<!-- title band (sections span every column before the section -->
<!-- total cell). The "not show_section_total" branch already -->
<!-- uses 99 so no change needed there. -->
<!-- ============================================================ -->
<xpath expr="//t[@t-set='section_name_colspan'][1]" position="attributes">
<attribute name="t-value">4 + (1 if display_discount else 0) + (1 if display_taxes else 0)</attribute>
</xpath>
</template>
</odoo>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Quality (QMS)', 'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.6.2.0', 'version': '19.0.6.6.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.', 'internal audits, customer specs, document control. CE + EE compatible.',
@@ -128,6 +128,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss', 'fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss',
'fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml', 'fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml',
'fusion_plating_quality/static/src/js/fp_quality_dashboard.js', 'fusion_plating_quality/static/src/js/fp_quality_dashboard.js',
# Contract Review flow — forced redirect into QA-005 after
# an estimator creates a part under a toggle-on customer.
# Pairs with the bus.bus push in fp_part_catalog.py:create.
'fusion_plating_quality/static/src/js/contract_review_redirect.js',
], ],
}, },
'installable': True, 'installable': True,

View File

@@ -23,11 +23,30 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id') @api.onchange('part_catalog_id')
def _onchange_part_default_spec(self): def _onchange_part_default_spec(self):
"""Pre-fill the line's specification from the part's default.""" """Pre-fill the line's specification.
Priority (first non-empty result wins, matches the same
"remember last entered" pattern used by process / unit_price /
tax / thickness in the base wizard line):
1. What the operator already typed on this line — never clobber
2. Most recent SO line for (part, customer) where a spec
was set — the "repeat order" carry-over
3. Part's stored default — x_fc_default_customer_spec_id
"""
for rec in self: for rec in self:
if (rec.part_catalog_id if not rec.part_catalog_id or rec.customer_spec_id:
and rec.part_catalog_id.x_fc_default_customer_spec_id continue
and not rec.customer_spec_id): partner = rec.wizard_id.partner_id if rec.wizard_id else False
if partner:
recent = self.env['sale.order.line'].search([
('x_fc_part_catalog_id', '=', rec.part_catalog_id.id),
('order_id.partner_id', '=', partner.id),
('x_fc_customer_spec_id', '!=', False),
], order='create_date desc', limit=1)
if recent:
rec.customer_spec_id = recent.x_fc_customer_spec_id
continue
if rec.part_catalog_id.x_fc_default_customer_spec_id:
rec.customer_spec_id = ( rec.customer_spec_id = (
rec.part_catalog_id.x_fc_default_customer_spec_id rec.part_catalog_id.x_fc_default_customer_spec_id
) )
@@ -37,12 +56,22 @@ class FpDirectOrderWizard(models.Model):
_inherit = 'fp.direct.order.wizard' _inherit = 'fp.direct.order.wizard'
def action_create_order(self): def action_create_order(self):
"""Carry customer_spec_id from each wizard line to its SO line. """Carry customer_spec_id from each wizard line to its SO line,
and (when the operator opted in via push_to_defaults) save the
spec back to the part as its new default so the next order
auto-fills without relying on a SO-line history lookup.
The base method (in configurator) builds the SO with all the The base method (in configurator) builds the SO with all the
coating/treatment/process fields. We can't insert spec into the coating/treatment/process fields. We can't insert spec into the
vals dict from here without a circular dep, so post-create we vals dict from here without a circular dep, so post-create we
pair wizard lines to SO lines by sequence and patch. pair wizard lines to SO lines by sequence and patch.
The push-to-defaults block mirrors the base wizard's thickness
push (action_create_order's "6. Push-to-defaults" loop) — spec
lives here in the quality module so the back-write lives here
too. Only fills when the part default is currently empty so we
never clobber an existing default that the part-form user set
explicitly.
""" """
result = super().action_create_order() result = super().action_create_order()
if self.sale_order_id: if self.sale_order_id:
@@ -55,4 +84,15 @@ class FpDirectOrderWizard(models.Model):
for wiz_line, so_line in zip(wiz_lines, so_lines): for wiz_line, so_line in zip(wiz_lines, so_lines):
if wiz_line.customer_spec_id and not so_line.x_fc_customer_spec_id: if wiz_line.customer_spec_id and not so_line.x_fc_customer_spec_id:
so_line.x_fc_customer_spec_id = wiz_line.customer_spec_id.id so_line.x_fc_customer_spec_id = wiz_line.customer_spec_id.id
# Spec push-to-default — only on first-time parts that
# had the toggle auto-ticked (or manually ticked). Skip
# one-off parts and parts that already have a default.
if (wiz_line.push_to_defaults
and not wiz_line.is_one_off
and wiz_line.customer_spec_id
and wiz_line.part_catalog_id
and not wiz_line.part_catalog_id.x_fc_default_customer_spec_id):
wiz_line.part_catalog_id.x_fc_default_customer_spec_id = (
wiz_line.customer_spec_id.id
)
return result return result

View File

@@ -208,11 +208,8 @@ class FpPartCatalog(models.Model):
% part_label, % part_label,
'message': _( 'message': _(
'Customer %(c)s requires a Contract Review ' 'Customer %(c)s requires a Contract Review '
'(QA-005) on new parts. The review record ' '(QA-005) on new parts. Opening the review '
'has been pre-created — open it using the ' 'form now…'
'Contract Review smart button at the top '
'of the part form, or from your Activities '
'inbox.'
) % {'c': customer_label}, ) % {'c': customer_label},
'type': 'warning', 'type': 'warning',
'sticky': True, 'sticky': True,
@@ -224,6 +221,30 @@ class FpPartCatalog(models.Model):
'review notification', part.id, exc_info=True, 'review notification', part.id, exc_info=True,
) )
# 3) Forced redirect into the QA-005 form. The frontend
# listener (see fusion_plating_quality/static/src/js/
# contract_review_redirect.js) intercepts this bus
# notification and calls action.doAction() to navigate.
# Rule 3 of the contract-review flow: customer-toggle-on
# parts force the QA-005 to open right after save so the
# estimator can complete (or visibly defer) the review
# before moving on.
try:
Bus._sendone(
self.env.user.partner_id,
'fusion_plating.contract_review_redirect',
{
'review_id': review.id,
'part_id': part.id,
'part_label': part_label,
},
)
except Exception:
_logger.warning(
'fp.part.catalog %s: could not push contract-'
'review redirect bus message', part.id, exc_info=True,
)
# ---- Actions ------------------------------------------------------------- # ---- Actions -------------------------------------------------------------
def action_start_contract_review(self): def action_start_contract_review(self):

View File

@@ -19,7 +19,7 @@ class ResCompany(models.Model):
'res_company_qa_assistant_rel', 'res_company_qa_assistant_rel',
'company_id', 'company_id',
'user_id', 'user_id',
string='QA Assistant Signers', string='Planning Signers',
domain=[('share', '=', False)], domain=[('share', '=', False)],
help='Users authorised to sign Section 2.0 (Planning / Production ' help='Users authorised to sign Section 2.0 (Planning / Production '
'Review) on a Contract Review. Plating Managers can sign ' 'Review) on a Contract Review. Plating Managers can sign '
@@ -30,11 +30,13 @@ class ResCompany(models.Model):
'res_company_qa_manager_rel', 'res_company_qa_manager_rel',
'company_id', 'company_id',
'user_id', 'user_id',
string='QA Manager Signers', string='QA Manager',
domain=[('share', '=', False)], domain=[('share', '=', False)],
help='Users authorised to sign Section 3.0 (Quality Review) on a ' help='QA Manager(s) for the shop. Signs Section 3.0 (Quality '
'Contract Review. Plating Managers can sign regardless of ' 'Review) on a Contract Review AND is the default Certified '
'this list.', 'By signer on the Work Order Detail report (first user in '
'the list is used for the cert signature). Plating Managers '
'can sign Contract Reviews regardless of this list.',
) )
def _fp_get_qa_signers(self, section): def _fp_get_qa_signers(self, section):

View File

@@ -15,10 +15,10 @@ class ResConfigSettings(models.TransientModel):
x_fc_qa_assistant_user_ids = fields.Many2many( x_fc_qa_assistant_user_ids = fields.Many2many(
related='company_id.x_fc_qa_assistant_user_ids', related='company_id.x_fc_qa_assistant_user_ids',
readonly=False, readonly=False,
string='QA Assistant Signers', string='Planning Signers',
) )
x_fc_qa_manager_user_ids = fields.Many2many( x_fc_qa_manager_user_ids = fields.Many2many(
related='company_id.x_fc_qa_manager_user_ids', related='company_id.x_fc_qa_manager_user_ids',
readonly=False, readonly=False,
string='QA Manager Signers', string='QA Manager',
) )

View File

@@ -0,0 +1,51 @@
/** @odoo-module **/
/*
* Copyright 2026 Nexa Systems Inc.
* License OPL-1 (Odoo Proprietary License v1.0)
* Part of the Fusion Plating product family.
*
* Forces the QA-005 Contract Review form to open right after an
* estimator creates a new fp.part.catalog under a customer that has
* x_fc_contract_review_required = True. Rule 3 of the contract-review
* flow.
*
* Server side (fusion_plating_quality/models/fp_part_catalog.py):
* after auto-creating the fp.contract.review record on part create,
* push a bus notification of type "fusion_plating.contract_review_redirect"
* with payload {review_id, part_id, part_label}.
*
* Frontend side (this file): a service subscribes to that bus type
* on session start. When a payload arrives, dispatch an ir.actions.
* act_window opening the review's form so the user lands on the QA-005
* automatically. They can still close it and come back later — the
* WO-step gate (rule 5) is the backstop.
*/
import { registry } from "@web/core/registry";
const contractReviewRedirectService = {
dependencies: ["bus_service", "action"],
start(env, { bus_service: bus, action }) {
bus.subscribe(
"fusion_plating.contract_review_redirect",
(payload) => {
if (!payload || !payload.review_id) {
return;
}
action.doAction({
type: "ir.actions.act_window",
res_model: "fp.contract.review",
res_id: payload.review_id,
view_mode: "form",
views: [[false, "form"]],
target: "current",
});
},
);
bus.start();
},
};
registry
.category("services")
.add("fusion_plating_contract_review_redirect", contractReviewRedirectService);

View File

@@ -22,7 +22,7 @@
permissions are affected. Plating Managers can permissions are affected. Plating Managers can
always sign regardless of these lists."> always sign regardless of these lists.">
<setting id="fp_qa_assistant_signers" <setting id="fp_qa_assistant_signers"
string="QA Assistant Signers" string="Planning Signers"
help="Users authorised to sign Section 2.0 help="Users authorised to sign Section 2.0
(Planning / Production Review). Typically (Planning / Production Review). Typically
one or two people. Leave empty if any one or two people. Leave empty if any
@@ -33,10 +33,13 @@
widget="many2many_tags"/> widget="many2many_tags"/>
</setting> </setting>
<setting id="fp_qa_manager_signers" <setting id="fp_qa_manager_signers"
string="QA Manager Signers" string="QA Manager"
help="Users authorised to sign Section 3.0 help="QA Manager(s) for the shop. Signs
(Quality Review). Typically the QA Section 3.0 (Quality Review) of the
Manager for the shop."> Contract Review, AND appears as the
Certified By signer on the Work Order
Detail report. The first user in the
list is used for the cert signature.">
<field name="x_fc_qa_manager_user_ids" <field name="x_fc_qa_manager_user_ids"
widget="many2many_tags"/> widget="many2many_tags"/>
</setting> </setting>

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.11.1.0', 'version': '19.0.11.14.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

@@ -307,6 +307,9 @@
<field name="header_line" eval="False"/> <field name="header_line" eval="False"/>
<field name="header_spacing">0</field> <field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/> <field name="disable_shrinking" eval="True"/>
<!-- dpi=300 is the calibrated value for this paperformat; the
sticker inner's px-based geometry is tuned against it. Do
NOT bump (see CLAUDE.md rule 14 — 600 broke layout). -->
<field name="dpi">300</field> <field name="dpi">300</field>
</record> </record>

View File

@@ -6,14 +6,17 @@
Shared CSS for all Fusion Plating reports (portrait + landscape). Shared CSS for all Fusion Plating reports (portrait + landscape).
The primary colour is driven by the active company's Section-header band: #c1c1c1 (neutral grey) with #4e4e4e text
res.company.primary_color field (Settings → Company → Report Layout), Document titles (h2/h4): #4e4e4e
falling back to #1d1f1e when the company has no brand colour set. Hardcoded — used to follow `res.company.primary_color` but the
client wanted a uniform neutral palette across every FP report
regardless of company branding. `fp_primary` is kept in scope for
any per-report template that still wants the company colour.
To keep section-header markup concise in individual report files, To keep section-header markup concise in individual report files,
a utility class `.fp-header-primary` is exposed — apply that class a utility class `.fp-header-primary` is exposed — apply that class
to any `<th>` or `<td>` that should render as a primary-coloured to any `<th>` or `<td>` that should render as a section banner
section banner (e.g. CARGO DESCRIPTION, PAYMENT DETAILS). (e.g. CARGO DESCRIPTION, PAYMENT DETAILS).
--> -->
<odoo> <odoo>
<!-- ============================================================= --> <!-- ============================================================= -->
@@ -24,10 +27,28 @@
<t t-set="fp_primary" t-value="(_fp_company.primary_color if _fp_company else False) or '#1d1f1e'"/> <t t-set="fp_primary" t-value="(_fp_company.primary_color if _fp_company else False) or '#1d1f1e'"/>
<style> <style>
.fp-report { font-family: Arial, sans-serif; font-size: 10pt; color: #000; } .fp-report { font-family: Arial, sans-serif; font-size: 10pt; color: #000; }
.fp-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; } .fp-report table { width: 100%; border-collapse: collapse; border-spacing: 0; margin-bottom: 10px; }
.fp-report table.bordered, .fp-report table.bordered th, .fp-report table.bordered td { border: 1px solid #000; } /* Standard collapse + longhand borders + background-clip.
.fp-report th { background-color: <t t-out="fp_primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; font-size: 9pt; } Tried border-collapse:separate with single-side-per-cell
.fp-report td { padding: 6px 8px; vertical-align: top; font-size: 10pt; } (right+bottom on cell, top+left on table) to fix wkhtmltopdf's
slightly-lighter-verticals quirk — but the `separate` model
makes column widths drift between tables with different
column counts, so tables stacked on the page no longer
line up at the outer edges. Reverted. The collapse pattern
gives correct alignment; the verticals may render a hair
softer than horizontals on entech wkhtmltopdf but that's
the less-bad trade-off vs misaligned tables. */
.fp-report table.bordered { border: 0; border-collapse: collapse; border-spacing: 0; }
.fp-report table.bordered th,
.fp-report table.bordered td {
border-width: 1px;
border-style: solid;
border-color: #000;
background-clip: padding-box;
box-sizing: border-box;
}
.fp-report th { background-color: #c1c1c1; color: #1d1f1e; padding: 6px 8px; font-weight: bold; text-align: center; font-size: 9pt; background-clip: padding-box; }
.fp-report td { padding: 6px 8px; vertical-align: top; font-size: 10pt; background-clip: padding-box; }
.fp-report .text-center { text-align: center; } .fp-report .text-center { text-align: center; }
.fp-report .text-end { text-align: right; } .fp-report .text-end { text-align: right; }
.fp-report .text-start { text-align: left; } .fp-report .text-start { text-align: left; }
@@ -35,20 +56,45 @@
.fp-report .client-bg { background-color: #fff3e0; } .fp-report .client-bg { background-color: #fff3e0; }
.fp-report .section-row { background-color: #f0f0f0; font-weight: bold; } .fp-report .section-row { background-color: #f0f0f0; font-weight: bold; }
.fp-report .note-row { font-style: italic; color: #555; font-size: 9pt; } .fp-report .note-row { font-style: italic; color: #555; font-size: 9pt; }
.fp-report h4 { color: <t t-out="fp_primary"/>; margin: 0 0 15px 0; font-size: 16pt; } .fp-report h4 { color: #2e2e2e; margin: 0 0 15px 0; font-size: 20pt; }
.fp-report .totals-table { border: 1px solid #000; border-collapse: collapse; } .fp-report .totals-table { border: 0; border-collapse: collapse; border-spacing: 0; }
.fp-report .totals-table td { border: 1px solid #000; padding: 6px 8px; } .fp-report .totals-table td {
border-width: 1px;
border-style: solid;
border-color: #000;
padding: 6px 8px;
background-clip: padding-box;
box-sizing: border-box;
}
.fp-report .info-header { background-color: #f5f5f5; color: #333; } .fp-report .info-header { background-color: #f5f5f5; color: #333; }
.fp-report .adp-header { background-color: #e3f2fd; color: #333; } .fp-report .adp-header { background-color: #e3f2fd; color: #333; }
.fp-report .highlight-box { border: 2px solid <t t-out="fp_primary"/>; background-color: #eaf2f8; padding: 10px; margin: 10px 0; } .fp-report .highlight-box { border: 2px solid #c1c1c1; background-color: #f5f5f5; padding: 10px; margin: 10px 0; }
.fp-report .fp-header-primary { background-color: <t t-out="fp_primary"/>; color: white; } .fp-report .fp-header-primary { background-color: #c1c1c1; color: #1d1f1e; }
.fp-report .paid-stamp { color: #28a745; font-size: 36pt; font-weight: bold; border: 4px solid #28a745; padding: 10px 20px; transform: rotate(-8deg); display: inline-block; } .fp-report .paid-stamp { color: #28a745; font-size: 36pt; font-weight: bold; border: 4px solid #28a745; padding: 10px 20px; transform: rotate(-8deg); display: inline-block; }
.fp-report .status-ok { color: #2e7d32; font-weight: bold; } .fp-report .status-ok { color: #2e7d32; font-weight: bold; }
.fp-report .status-warning { color: #f57f17; font-weight: bold; } .fp-report .status-warning { color: #f57f17; font-weight: bold; }
.fp-report .status-fail { color: #c62828; font-weight: bold; } .fp-report .status-fail { color: #c62828; font-weight: bold; }
.fp-report .sig-line { border-bottom: 1px solid #000; height: 60px; margin-bottom: 4px; } .fp-report .sig-line { border-bottom: 1px solid #000; height: 60px; margin-bottom: 4px; }
.fp-report .sig-table { width: 100%; border-collapse: collapse; margin-top: 16px; border: 1px solid #000; page-break-inside: avoid; break-inside: avoid; } .fp-report .sig-table {
.fp-report .sig-table .sig-cell { padding: 14px 12px 8px 12px; vertical-align: top; border: 1px solid #000; page-break-inside: avoid; break-inside: avoid; } width: 100%;
border-collapse: collapse;
border-spacing: 0;
margin-top: 16px;
border: 0;
page-break-inside: avoid;
break-inside: avoid;
}
.fp-report .sig-table .sig-cell {
padding: 14px 12px 8px 12px;
vertical-align: top;
border-width: 1px;
border-style: solid;
border-color: #000;
background-clip: padding-box;
box-sizing: border-box;
page-break-inside: avoid;
break-inside: avoid;
}
.fp-report .small-muted { font-size: 8pt; color: #666; } .fp-report .small-muted { font-size: 8pt; color: #666; }
.fp-report .fp-cell-mid { vertical-align: middle !important; } .fp-report .fp-cell-mid { vertical-align: middle !important; }
.fp-report .fp-keep-together { page-break-inside: avoid; break-inside: avoid; } .fp-report .fp-keep-together { page-break-inside: avoid; break-inside: avoid; }
@@ -65,9 +111,18 @@
<t t-set="fp_primary" t-value="(_fp_company.primary_color if _fp_company else False) or '#1d1f1e'"/> <t t-set="fp_primary" t-value="(_fp_company.primary_color if _fp_company else False) or '#1d1f1e'"/>
<style> <style>
.fp-landscape { font-family: Arial, sans-serif; font-size: 10pt; color: #000; } .fp-landscape { font-family: Arial, sans-serif; font-size: 10pt; color: #000; }
.fp-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 6px; } .fp-landscape table { width: 100%; border-collapse: collapse; border-spacing: 0; margin-bottom: 6px; }
.fp-landscape table.bordered, .fp-landscape table.bordered th, .fp-landscape table.bordered td { border: 1px solid #000; } /* Standard collapse + longhand + background-clip — see comment in fp_portrait_styles. */
.fp-landscape th { background-color: <t t-out="fp_primary"/>; color: white; padding: 4px 8px; font-weight: bold; font-size: 9pt; } .fp-landscape table.bordered { border: 0; border-collapse: collapse; border-spacing: 0; }
.fp-landscape table.bordered th,
.fp-landscape table.bordered td {
border-width: 1px;
border-style: solid;
border-color: #000;
background-clip: padding-box;
box-sizing: border-box;
}
.fp-landscape th { background-color: #c1c1c1; color: #1d1f1e; padding: 4px 8px; font-weight: bold; font-size: 9pt; background-clip: padding-box; }
.fp-landscape td { padding: 4px 8px; vertical-align: top; font-size: 9.5pt; } .fp-landscape td { padding: 4px 8px; vertical-align: top; font-size: 9.5pt; }
.fp-landscape .text-center { text-align: center; } .fp-landscape .text-center { text-align: center; }
.fp-landscape .text-end { text-align: right; } .fp-landscape .text-end { text-align: right; }
@@ -76,13 +131,13 @@
.fp-landscape .client-bg { background-color: #fff3e0; } .fp-landscape .client-bg { background-color: #fff3e0; }
.fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; } .fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fp-landscape .note-row { font-style: italic; color: #555; } .fp-landscape .note-row { font-style: italic; color: #555; }
.fp-landscape h2 { color: <t t-out="fp_primary"/>; margin: 4px 0; font-size: 18pt; } .fp-landscape h2 { color: #2e2e2e; margin: 4px 0; font-size: 22pt; }
.fp-landscape .info-table td { padding: 8px 12px; font-size: 11pt; } .fp-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fp-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; } .fp-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fp-landscape .totals-table { border: 1px solid #000; } .fp-landscape .totals-table { border: 1px solid #000; }
.fp-landscape .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; } .fp-landscape .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; }
.fp-landscape .highlight-box { border: 2px solid <t t-out="fp_primary"/>; background-color: #eaf2f8; padding: 6px 10px; margin: 6px 0; font-size: 9pt; } .fp-landscape .highlight-box { border: 2px solid #c1c1c1; background-color: #f5f5f5; padding: 6px 10px; margin: 6px 0; font-size: 9pt; }
.fp-landscape .fp-header-primary { background-color: <t t-out="fp_primary"/>; color: white; } .fp-landscape .fp-header-primary { background-color: #c1c1c1; color: #1d1f1e; }
.fp-landscape .paid-stamp { color: #28a745; font-size: 42pt; font-weight: bold; border: 4px solid #28a745; padding: 10px 20px; transform: rotate(-8deg); display: inline-block; } .fp-landscape .paid-stamp { color: #28a745; font-size: 42pt; font-weight: bold; border: 4px solid #28a745; padding: 10px 20px; transform: rotate(-8deg); display: inline-block; }
.fp-landscape .status-ok { color: #2e7d32; font-weight: bold; } .fp-landscape .status-ok { color: #2e7d32; font-weight: bold; }
.fp-landscape .status-warning { color: #f57f17; font-weight: bold; } .fp-landscape .status-warning { color: #f57f17; font-weight: bold; }

View File

@@ -26,15 +26,16 @@
<!-- ================================================================== --> <!-- ================================================================== -->
<template id="coc_body"> <template id="coc_body">
<t t-set="is_fr" t-value="LANG == 'fr'"/> <t t-set="is_fr" t-value="LANG == 'fr'"/>
<t t-set="owner_sig" t-value="False"/> <!-- Signer + signature resolution (2026-05-17): unified with the
<t t-if="company.x_fc_owner_user_id"> WO Detail certifier pattern. Signer = cert's certified_by
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/> user; falls back to the company owner. Signature image is
<t t-if="_emp and 'signature' in _emp._fields"> that user's Plating Signature (x_fc_signature_image from
<t t-set="owner_sig" t-value="_emp['signature']"/> Preferences → My Profile). The previous HR Employee
</t> signature lookup was retired in favour of this single
</t> source so all FP reports pull from the same field. -->
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override or owner_sig"/> <t t-set="signer_user" t-value="doc.certified_by_id or company.x_fc_owner_user_id or False"/>
<t t-set="signer_name" t-value="doc.certified_by_id.name or (company.x_fc_owner_user_id.name if company.x_fc_owner_user_id else '')"/> <t t-set="signature_img" t-value="(signer_user and 'x_fc_signature_image' in signer_user._fields and signer_user.x_fc_signature_image) or False"/>
<t t-set="signer_name" t-value="(signer_user and signer_user.name) or ''"/>
<style> <style>
.fp-coc { font-family: Arial, sans-serif; font-size: 9pt; color: #000; .fp-coc { font-family: Arial, sans-serif; font-size: 9pt; color: #000;

View File

@@ -208,16 +208,13 @@
<hr class="heavy"/> <hr class="heavy"/>
<!-- Sign-off block (re-uses owner_user_id signature pattern from coc_body) --> <!-- Sign-off block — unified with WO Detail / CoC (2026-05-17).
<t t-set="owner_sig" t-value="False"/> Signer = cert's certified_by user → falls back to company
<t t-if="company.x_fc_owner_user_id"> owner. Signature image = signer's Plating Signature
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/> (x_fc_signature_image from Preferences → My Profile). -->
<t t-if="_emp and 'signature' in _emp._fields"> <t t-set="signer_user" t-value="doc.certified_by_id or company.x_fc_owner_user_id or False"/>
<t t-set="owner_sig" t-value="_emp['signature']"/> <t t-set="signature_img" t-value="(signer_user and 'x_fc_signature_image' in signer_user._fields and signer_user.x_fc_signature_image) or False"/>
</t> <t t-set="signer_name" t-value="(signer_user and signer_user.name) or ''"/>
</t>
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override or owner_sig"/>
<t t-set="signer_name" t-value="(doc.certified_by_id and doc.certified_by_id.name) or (company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<!-- Sub 12c+ — cert statement: per-customer override → company default → hardcoded fallback --> <!-- Sub 12c+ — cert statement: per-customer override → company default → hardcoded fallback -->
<t t-set="_cust_stmt" t-value="('x_fc_cert_statement' in doc.partner_id._fields and doc.partner_id.x_fc_cert_statement) or False"/> <t t-set="_cust_stmt" t-value="('x_fc_cert_statement' in doc.partner_id._fields and doc.partner_id.x_fc_cert_statement) or False"/>

View File

@@ -21,10 +21,10 @@
<div class="page"> <div class="page">
<h4> <h4>
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice </span> <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_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 == 'out_refund'">Credit Note # </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill </span> <span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h4> </h4>
@@ -152,7 +152,7 @@
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<tr style="background-color: #eaf2f8;"> <tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td> <td><strong>Grand Total</strong></td>
<td class="text-end"><strong> <td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
@@ -205,10 +205,10 @@
<div class="page"> <div class="page">
<h2 style="text-align: left;"> <h2 style="text-align: left;">
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice </span> <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_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 == 'out_refund'">Credit Note # </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill </span> <span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h2> </h2>
@@ -341,7 +341,7 @@
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<tr style="background-color: #eaf2f8;"> <tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td> <td><strong>Grand Total</strong></td>
<td class="text-end"><strong> <td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>

View File

@@ -20,7 +20,7 @@
<div class="page"> <div class="page">
<h4> <h4>
Packing Slip Packing Slip #
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h4> </h4>
@@ -153,7 +153,7 @@
<div class="page"> <div class="page">
<h2 style="text-align: left;"> <h2 style="text-align: left;">
Packing Slip Packing Slip #
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h2> </h2>

View File

@@ -22,8 +22,8 @@
<!-- Title --> <!-- Title -->
<h4> <h4>
<span t-if="doc.state in ['draft','sent']">Quotation </span> <span t-if="doc.state in ['draft','sent']">Quotation # </span>
<span t-else="">Sales Order </span> <span t-else="">Sales Order # </span>
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h4> </h4>
@@ -213,7 +213,7 @@
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<tr style="background-color: #eaf2f8;"> <tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td> <td><strong>Grand Total</strong></td>
<td class="text-end"><strong> <td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
@@ -282,8 +282,8 @@
<!-- Title --> <!-- Title -->
<h2 style="text-align: left;"> <h2 style="text-align: left;">
<span t-if="doc.state in ['draft','sent']">Quotation </span> <span t-if="doc.state in ['draft','sent']">Quotation # </span>
<span t-else="">Sales Order </span> <span t-else="">Sales Order # </span>
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h2> </h2>
@@ -492,7 +492,7 @@
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td> </td>
</tr> </tr>
<tr style="background-color: #eaf2f8;"> <tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td> <td><strong>Grand Total</strong></td>
<td class="text-end"><strong> <td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/> <span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>

View File

@@ -95,17 +95,27 @@
<!-- Notes content — outer can pre-set this (e.g. the Internal <!-- Notes content — outer can pre-set this (e.g. the Internal
variant passes line.x_fc_internal_description). Otherwise variant passes line.x_fc_internal_description). Otherwise
falls back to line.name (customer-facing description per falls back to line.name (customer-facing description per
Sub 2 Q6), then to part.name. --> Sub 2 Q6), then to part.name. Strip en/em-dash, smart
<t t-set="_notes_content" t-value="_notes_content quotes, and ellipsis defensively for the wkhtmltopdf font
or (_line and _line.name) path on entech — same treatment as thickness above, which
or (_part and _part.name) otherwise turns "—" into the "â€"" mojibake. -->
or '-'"/> <t t-set="_notes_raw" t-value="_notes_content
or (_line and _line.name)
or (_part and _part.name)
or '-'"/>
<t t-set="_notes_content" t-value="_notes_raw
.replace(u'—', '-').replace(u'', '-')
.replace(u'', &quot;'&quot;).replace(u'', &quot;'&quot;)
.replace(u'“', '&quot;').replace(u'”', '&quot;')
.replace(u'…', '...')"/>
<!-- 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.
600x600 source at 300dpi print = ~515ppi effective — high-def 1000x1000 source — Odoo core caps barcode area at 1.2M pixels
scan reliability for the 4x6" label. --> (`width * height > 1200000` raises "Barcode too large"), so we
stay under that ceiling. 1000x1000 at the 31mm wrapper gives
~821ppi effective — far above the 203dpi thermal printer. -->
<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(
'QR', _scan_url, width=600, height=600)"/> 'QR', _scan_url, width=1000, height=1000)"/>
<style> <style>
@page { margin: 0; size: 152mm 102mm; } @page { margin: 0; size: 152mm 102mm; }
@@ -173,7 +183,8 @@
adds around the QR pattern. We render the image larger than adds around the QR pattern. We render the image larger than
the wrapper and offset so the wrapper clips that border out. the wrapper and offset so the wrapper clips that border out.
Wrapper 365px = ~30.9mm at 300dpi (30% larger than the Wrapper 365px = ~30.9mm at 300dpi (30% larger than the
previous 280px). 600x600 source = high-def at print scale. ---- */ previous 280px). 1000x1000 source = print-sharp at the
paperformat DPI (under Odoo's 1.2M-pixel barcode cap). ---- */
.fp-sticker-qr-wrap { .fp-sticker-qr-wrap {
width: 365px; width: 365px;
height: 365px; height: 365px;
@@ -237,7 +248,13 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.fp-sticker-strong { font-weight: 700; } /* Values used to be bold via .fp-sticker-strong (PO/Part#/Qty).
Per ops, only the field title and the WO# header should be
bold; values stay regular weight. Font sizes unchanged from
the original layout — bumping them broke wkhtmltopdf's row
packing on entech, so we accept the same visual weight as
before. -->
.fp-sticker-strong { font-weight: 400; }
.fp-sticker-muted { color: #555; font-size: 30pt; } .fp-sticker-muted { color: #555; font-size: 30pt; }
/* Notes column on the right side of the body. */ /* Notes column on the right side of the body. */
.fp-notes-label { .fp-notes-label {
@@ -327,7 +344,10 @@
<tr> <tr>
<td class="fp-sticker-label">Part #:</td> <td class="fp-sticker-label">Part #:</td>
<td class="fp-sticker-value"> <td class="fp-sticker-value">
<t t-if="_part"> <t t-if="_multi_line">
<span class="fp-sticker-strong">Multiple Line Items</span>
</t>
<t t-elif="_part">
<span class="fp-sticker-strong" <span class="fp-sticker-strong"
t-esc="_part.part_number"/> t-esc="_part.part_number"/>
<t t-if="_part.revision"> <t t-if="_part.revision">
@@ -408,6 +428,11 @@
<t t-set="_scan_path" t-value="False"/> <t t-set="_scan_path" t-value="False"/>
<t t-set="_notes_content" t-value="False"/> <t t-set="_notes_content" t-value="False"/>
<t t-set="_qty_total" t-value="False"/> <t t-set="_qty_total" t-value="False"/>
<!-- _multi_line = True signals "this PO has multiple part lines";
Part # prints "Multiple Line Items", line-specific fields
(SN/Thickness/Notes) auto-resolve to "-" via _line=False, and
Qty shows the SO-line sum (outer sets _qty + _qty_total=1). -->
<t t-set="_multi_line" t-value="False"/>
</template> </template>
<!-- ========== Outer template — mrp.workorder entry ========== --> <!-- ========== Outer template — mrp.workorder entry ========== -->
@@ -454,24 +479,50 @@
<template id="report_fp_so_sticker"> <template id="report_fp_so_sticker">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-foreach="docs" t-as="so"> <t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)" <t t-set="_part_lines"
t-as="line"> t-value="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"/>
<t t-if="len(_part_lines) &gt;= 2">
<!-- Multi-line PO: one consolidated sticker.
Part # prints "Multiple Line Items", Qty is the
sum of all part-line qtys. Per-box loop disabled
(_qty_total=1) — the consolidated label is the
master-skid label, not per-physical-box. -->
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/> <t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/> <t t-set="_scan_id" t-value="_part_lines[:1].id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/> <t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/> <t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="so"/> <t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/> <t t-set="_line" t-value="False"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/> <t t-set="_part" t-value="False"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/> <t t-set="_spec" t-value="False"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/> <t t-set="_due" t-value="so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/> <t t-set="_qty" t-value="sum(_part_lines.mapped('product_uom_qty'))"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/> <t t-set="_qty_total" t-value="1"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/> <t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/> <t t-set="_mo_ref" t-value="''"/>
<t t-set="_multi_line" t-value="True"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t> </t>
<t t-else="">
<t t-foreach="_part_lines" t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</t> </t>
</t> </t>
</template> </template>
@@ -484,27 +535,52 @@
<template id="report_fp_so_sticker_internal"> <template id="report_fp_so_sticker_internal">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-foreach="docs" t-as="so"> <t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)" <t t-set="_part_lines"
t-as="line"> t-value="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"/>
<t t-if="len(_part_lines) &gt;= 2">
<!-- Multi-line PO: one consolidated sticker.
Notes column blanked ("-") because each line has
its own internal description. -->
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/> <t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/> <t t-set="_scan_id" t-value="_part_lines[:1].id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/> <t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/> <t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="so"/> <t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/> <t t-set="_line" t-value="False"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/> <t t-set="_part" t-value="False"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/> <t t-set="_spec" t-value="False"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/> <t t-set="_due" t-value="so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/> <t t-set="_qty" t-value="sum(_part_lines.mapped('product_uom_qty'))"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/> <t t-set="_qty_total" t-value="1"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/> <t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/> <t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description --> <t t-set="_multi_line" t-value="True"/>
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields <t t-set="_notes_content" t-value="'-'"/>
and line.x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/> <t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t> </t>
<t t-else="">
<t t-foreach="_part_lines" t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description -->
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
and line.x_fc_internal_description) or '-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</t> </t>
</t> </t>
</template> </template>

View File

@@ -20,7 +20,7 @@
<div class="page"> <div class="page">
<h4> <h4>
Work Order Traveller Work Order Traveller #
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h4> </h4>
@@ -265,7 +265,7 @@
<div class="page"> <div class="page">
<h2 style="text-align: left;"> <h2 style="text-align: left;">
Work Order Traveller Work Order Traveller #
<span t-field="doc.name"/> <span t-field="doc.name"/>
</h2> </h2>