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:
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.
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.
---

View File

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

View File

@@ -33,6 +33,11 @@ class FpProcessNode(models.Model):
_parent_name = 'parent_id'
_order = 'parent_path, sequence, id'
_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 ------------------------------------------------

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.6.0.0',
'version': '19.0.6.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -15,9 +15,10 @@ class ResConfigSettings(models.TransientModel):
x_fc_owner_user_id = fields.Many2one(
related='company_id.x_fc_owner_user_id', readonly=False,
)
x_fc_coc_signature_override = fields.Binary(
related='company_id.x_fc_coc_signature_override', readonly=False,
)
# x_fc_coc_signature_override was retired 2026-05-17 — cert
# 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(
related='company_id.x_fc_nadcap_logo', readonly=False,
)

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ class FpDirectOrderLine(models.Model):
process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
domain="[('id', 'in', recipe_choice_ids)]",
ondelete='set null',
help='Pick any recipe — the part\'s own variant, another part\'s '
'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 '
'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(
string='Set as Part Default',
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).
has_default_spec = bool(getattr(
part, 'x_fc_default_customer_spec_id', False))
# New-part auto-suggest: if no default spec exists, this is
# likely 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.
# The quality inherit's _onchange_part_default_spec ALSO
# falls back to the most recent SO line for (part, customer)
# when the part default is empty. If that lookup will find
# a hit, this is NOT a first-time use from the operator's
# perspective — the spec will silently pre-fill from history.
# 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
and not has_history_spec
and not rec.is_one_off
and not rec.push_to_defaults):
rec.push_to_defaults = True
@@ -512,17 +570,61 @@ class FpDirectOrderLine(models.Model):
def _onchange_part_defaults(self):
"""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
(the customer_spec_id field lives there).
"""
if not self.part_catalog_id:
return
# Seed default taxes from the FP-SERVICE product, fiscal-position
# mapped from the customer. Only fills when the user hasn't set
# taxes manually.
self._fp_seed_from_last_so_line()
# Fall back to product taxes if no prior SO line found and the
# operator hasn't set taxes manually.
if not self.tax_ids:
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):
"""Pick taxes from the FP-SERVICE product, mapped through the
customer's fiscal position when one is set."""
@@ -690,9 +792,11 @@ class FpDirectOrderLine(models.Model):
return clone
def _fp_apply_recipe_polish(self):
"""Post-write step: auto-clone any cross-part recipe pick and
honour the Save-as-Default toggle. Called from create() and
write()."""
"""Post-write step: auto-clone any cross-part recipe pick, set
the freshly-cloned recipe as the part's default IF this is the
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:
if not line.part_catalog_id or not line.process_variant_id:
continue
@@ -703,7 +807,19 @@ class FpDirectOrderLine(models.Model):
if clone and clone.id != recipe.id:
line.process_variant_id = clone.id
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)
def action_customize_process(self):

View File

@@ -552,13 +552,19 @@ class FpDirectOrderWizard(models.Model):
resolved_parts[line.id] = part
# Build the line header. Specification is optional; when
# 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_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,
part.name,
part.revision,
part_label or _('Unspecified part'),
rev_suffix,
line.quantity,
)
extended = (line.line_description or '').strip()

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.2.0',
'version': '19.0.10.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -25,6 +25,7 @@
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/>
<!-- dpi=300 calibrated — see CLAUDE.md rule 14, 600 broke layout. -->
<field name="dpi">300</field>
</record>
@@ -47,6 +48,14 @@
reads (so `_so or ...` doesn't NameError). We
then override the ones we have data for. -->
<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
expects, sourcing them from fp.job's native fields. -->
<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="_mo" t-value="False"/>
<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="_part" t-value="('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="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/>
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<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="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_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.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="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else 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"/>
<!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress
@@ -91,25 +100,31 @@
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<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="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<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="_part" t-value="('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="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_qty_total" t-value="job.qty"/>
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<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="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_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.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="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else 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="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description from
the first linked SO line. -->
<t t-set="_notes_content" t-value="(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 '-'"/>
the first linked SO line. Multi-line PO blanks it
since each line has its own description. -->
<t t-set="_notes_content" t-value="'-' if _multi_line else
((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>

View File

@@ -114,7 +114,7 @@
<div class="page fp-wo-detail">
<style>
.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 .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
.fp-wo-detail table.bordered,
@@ -134,8 +134,8 @@
keeps captions glued to their image. */
.fp-wo-detail .fp-photo-section { margin-top: 18px; }
.fp-wo-detail .fp-photo-section h2 {
font-size: 13pt; font-weight: bold; color: #1a4d80;
margin: 0 0 8px 0; border-bottom: 2px solid #1a4d80;
font-size: 13pt; font-weight: bold; color: #2e2e2e;
margin: 0 0 8px 0; border-bottom: 2px solid #c1c1c1;
padding-bottom: 3px;
}
.fp-wo-detail .fp-photo-grid {
@@ -162,7 +162,7 @@
font-size: 8pt; color: #444; line-height: 1.25;
}
.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;
}
/* Inline signature image inside the step
@@ -243,6 +243,65 @@
</tr>
</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):
<span style="font-weight: normal;"
t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/>
@@ -481,21 +540,30 @@
<div style="page-break-before: always;"/>
<div style="height: 8mm;"/>
<!-- Certifier = the job's plating manager. Pulls
their Plating Signature (`x_fc_signature_image`)
from Preferences → My Profile. Falls back to
the company owner's signature, then to the
settings override only if no user has one. -->
<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)"/>
<!-- Certifier = the company's QA Manager, set in
Settings → Fusion Plating → Contract Review.
Falls back to the job's plating manager, then
the company owner, then the settings signature
override. Pulls the certifier's Plating
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-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>
<!-- Final fallback: company-level override for sites
whose certifier hasn't uploaded their signature yet. -->
<t t-if="not signature_img and 'x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override">
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override"/>
</t>
<!-- Signature Override Image fallback retired
2026-05-17. If no certifier user has uploaded
their Plating Signature, the cert prints
without a signature image (Name still shows). -->
<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"/>

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)',
'version': '19.0.6.2.0',
'version': '19.0.6.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'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/xml/fp_quality_dashboard.xml',
'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,

View File

@@ -23,11 +23,30 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
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:
if (rec.part_catalog_id
and rec.part_catalog_id.x_fc_default_customer_spec_id
and not rec.customer_spec_id):
if not rec.part_catalog_id or rec.customer_spec_id:
continue
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.part_catalog_id.x_fc_default_customer_spec_id
)
@@ -37,12 +56,22 @@ class FpDirectOrderWizard(models.Model):
_inherit = 'fp.direct.order.wizard'
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
coating/treatment/process fields. We can't insert spec into the
vals dict from here without a circular dep, so post-create we
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()
if self.sale_order_id:
@@ -55,4 +84,15 @@ class FpDirectOrderWizard(models.Model):
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:
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

View File

@@ -208,11 +208,8 @@ class FpPartCatalog(models.Model):
% part_label,
'message': _(
'Customer %(c)s requires a Contract Review '
'(QA-005) on new parts. The review record '
'has been pre-created — open it using the '
'Contract Review smart button at the top '
'of the part form, or from your Activities '
'inbox.'
'(QA-005) on new parts. Opening the review '
'form now…'
) % {'c': customer_label},
'type': 'warning',
'sticky': True,
@@ -224,6 +221,30 @@ class FpPartCatalog(models.Model):
'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 -------------------------------------------------------------
def action_start_contract_review(self):

View File

@@ -19,7 +19,7 @@ class ResCompany(models.Model):
'res_company_qa_assistant_rel',
'company_id',
'user_id',
string='QA Assistant Signers',
string='Planning Signers',
domain=[('share', '=', False)],
help='Users authorised to sign Section 2.0 (Planning / Production '
'Review) on a Contract Review. Plating Managers can sign '
@@ -30,11 +30,13 @@ class ResCompany(models.Model):
'res_company_qa_manager_rel',
'company_id',
'user_id',
string='QA Manager Signers',
string='QA Manager',
domain=[('share', '=', False)],
help='Users authorised to sign Section 3.0 (Quality Review) on a '
'Contract Review. Plating Managers can sign regardless of '
'this list.',
help='QA Manager(s) for the shop. Signs Section 3.0 (Quality '
'Review) on a Contract Review AND is the default Certified '
'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):

View File

@@ -15,10 +15,10 @@ class ResConfigSettings(models.TransientModel):
x_fc_qa_assistant_user_ids = fields.Many2many(
related='company_id.x_fc_qa_assistant_user_ids',
readonly=False,
string='QA Assistant Signers',
string='Planning Signers',
)
x_fc_qa_manager_user_ids = fields.Many2many(
related='company_id.x_fc_qa_manager_user_ids',
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
always sign regardless of these lists.">
<setting id="fp_qa_assistant_signers"
string="QA Assistant Signers"
string="Planning Signers"
help="Users authorised to sign Section 2.0
(Planning / Production Review). Typically
one or two people. Leave empty if any
@@ -33,10 +33,13 @@
widget="many2many_tags"/>
</setting>
<setting id="fp_qa_manager_signers"
string="QA Manager Signers"
help="Users authorised to sign Section 3.0
(Quality Review). Typically the QA
Manager for the shop.">
string="QA Manager"
help="QA Manager(s) for the shop. Signs
Section 3.0 (Quality Review) of the
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"
widget="many2many_tags"/>
</setting>

View File

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

View File

@@ -307,6 +307,9 @@
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<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>
</record>

View File

@@ -6,14 +6,17 @@
Shared CSS for all Fusion Plating reports (portrait + landscape).
The primary colour is driven by the active company's
res.company.primary_color field (Settings → Company → Report Layout),
falling back to #1d1f1e when the company has no brand colour set.
Section-header band: #c1c1c1 (neutral grey) with #4e4e4e text
Document titles (h2/h4): #4e4e4e
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,
a utility class `.fp-header-primary` is exposed — apply that class
to any `<th>` or `<td>` that should render as a primary-coloured
section banner (e.g. CARGO DESCRIPTION, PAYMENT DETAILS).
to any `<th>` or `<td>` that should render as a section banner
(e.g. CARGO DESCRIPTION, PAYMENT DETAILS).
-->
<odoo>
<!-- ============================================================= -->
@@ -24,10 +27,28 @@
<t t-set="fp_primary" t-value="(_fp_company.primary_color if _fp_company else False) or '#1d1f1e'"/>
<style>
.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.bordered, .fp-report table.bordered th, .fp-report table.bordered td { border: 1px solid #000; }
.fp-report th { background-color: <t t-out="fp_primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; font-size: 9pt; }
.fp-report td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fp-report table { width: 100%; border-collapse: collapse; border-spacing: 0; margin-bottom: 10px; }
/* Standard collapse + longhand borders + background-clip.
Tried border-collapse:separate with single-side-per-cell
(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-end { text-align: right; }
.fp-report .text-start { text-align: left; }
@@ -35,20 +56,45 @@
.fp-report .client-bg { background-color: #fff3e0; }
.fp-report .section-row { background-color: #f0f0f0; font-weight: bold; }
.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 .totals-table { border: 1px solid #000; border-collapse: collapse; }
.fp-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
.fp-report h4 { color: #2e2e2e; margin: 0 0 15px 0; font-size: 20pt; }
.fp-report .totals-table { border: 0; border-collapse: collapse; border-spacing: 0; }
.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 .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 .fp-header-primary { background-color: <t t-out="fp_primary"/>; color: white; }
.fp-report .highlight-box { border: 2px solid #c1c1c1; background-color: #f5f5f5; padding: 10px; margin: 10px 0; }
.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 .status-ok { color: #2e7d32; font-weight: bold; }
.fp-report .status-warning { color: #f57f17; 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-table { width: 100%; border-collapse: collapse; margin-top: 16px; border: 1px solid #000; page-break-inside: avoid; break-inside: avoid; }
.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; }
.fp-report .sig-table {
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 .fp-cell-mid { vertical-align: middle !important; }
.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'"/>
<style>
.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.bordered, .fp-landscape table.bordered th, .fp-landscape table.bordered td { border: 1px solid #000; }
.fp-landscape th { background-color: <t t-out="fp_primary"/>; color: white; padding: 4px 8px; font-weight: bold; font-size: 9pt; }
.fp-landscape table { width: 100%; border-collapse: collapse; border-spacing: 0; margin-bottom: 6px; }
/* Standard collapse + longhand + background-clip — see comment in fp_portrait_styles. */
.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 .text-center { text-align: center; }
.fp-landscape .text-end { text-align: right; }
@@ -76,13 +131,13 @@
.fp-landscape .client-bg { background-color: #fff3e0; }
.fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.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 th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.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 .highlight-box { border: 2px solid <t t-out="fp_primary"/>; background-color: #eaf2f8; 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 .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: #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 .status-ok { color: #2e7d32; font-weight: bold; }
.fp-landscape .status-warning { color: #f57f17; font-weight: bold; }

View File

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

View File

@@ -208,16 +208,13 @@
<hr class="heavy"/>
<!-- Sign-off block (re-uses owner_user_id signature pattern from coc_body) -->
<t t-set="owner_sig" t-value="False"/>
<t t-if="company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
</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 ''"/>
<!-- Sign-off block — unified with WO Detail / CoC (2026-05-17).
Signer = cert's certified_by user → falls back to company
owner. Signature image = signer's Plating Signature
(x_fc_signature_image from Preferences → My Profile). -->
<t t-set="signer_user" t-value="doc.certified_by_id or company.x_fc_owner_user_id or False"/>
<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 ''"/>
<!-- 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"/>

View File

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

View File

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

View File

@@ -22,8 +22,8 @@
<!-- Title -->
<h4>
<span t-if="doc.state in ['draft','sent']">Quotation </span>
<span t-else="">Sales Order </span>
<span t-if="doc.state in ['draft','sent']">Quotation # </span>
<span t-else="">Sales Order # </span>
<span t-field="doc.name"/>
</h4>
@@ -213,7 +213,7 @@
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
@@ -282,8 +282,8 @@
<!-- Title -->
<h2 style="text-align: left;">
<span t-if="doc.state in ['draft','sent']">Quotation </span>
<span t-else="">Sales Order </span>
<span t-if="doc.state in ['draft','sent']">Quotation # </span>
<span t-else="">Sales Order # </span>
<span t-field="doc.name"/>
</h2>
@@ -492,7 +492,7 @@
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<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
variant passes line.x_fc_internal_description). Otherwise
falls back to line.name (customer-facing description per
Sub 2 Q6), then to part.name. -->
<t t-set="_notes_content" t-value="_notes_content
or (_line and _line.name)
or (_part and _part.name)
or '-'"/>
Sub 2 Q6), then to part.name. Strip en/em-dash, smart
quotes, and ellipsis defensively for the wkhtmltopdf font
path on entech — same treatment as thickness above, which
otherwise turns "—" into the "â€"" mojibake. -->
<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
to fetch /report/barcode/ over the network during rendering.
600x600 source at 300dpi print = ~515ppi effective — high-def
scan reliability for the 4x6" label. -->
1000x1000 source — Odoo core caps barcode area at 1.2M pixels
(`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(
'QR', _scan_url, width=600, height=600)"/>
'QR', _scan_url, width=1000, height=1000)"/>
<style>
@page { margin: 0; size: 152mm 102mm; }
@@ -173,7 +183,8 @@
adds around the QR pattern. We render the image larger than
the wrapper and offset so the wrapper clips that border out.
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 {
width: 365px;
height: 365px;
@@ -237,7 +248,13 @@
text-overflow: ellipsis;
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; }
/* Notes column on the right side of the body. */
.fp-notes-label {
@@ -327,7 +344,10 @@
<tr>
<td class="fp-sticker-label">Part #:</td>
<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"
t-esc="_part.part_number"/>
<t t-if="_part.revision">
@@ -408,6 +428,11 @@
<t t-set="_scan_path" t-value="False"/>
<t t-set="_notes_content" 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>
<!-- ========== Outer template — mrp.workorder entry ========== -->
@@ -454,24 +479,50 @@
<template id="report_fp_so_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-set="_part_lines"
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-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="_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="_line" t-value="False"/>
<t t-set="_part" t-value="False"/>
<t t-set="_spec" t-value="False"/>
<t t-set="_due" t-value="so.commitment_date or False"/>
<t t-set="_qty" t-value="sum(_part_lines.mapped('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="_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 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>
</template>
@@ -484,27 +535,52 @@
<template id="report_fp_so_sticker_internal">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-set="_part_lines"
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-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="_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="_line" t-value="False"/>
<t t-set="_part" t-value="False"/>
<t t-set="_spec" t-value="False"/>
<t t-set="_due" t-value="so.commitment_date or False"/>
<t t-set="_qty" t-value="sum(_part_lines.mapped('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="_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-set="_multi_line" t-value="True"/>
<t t-set="_notes_content" t-value="'-'"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</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>
</template>

View File

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