Compare commits
16 Commits
894eea7ce2
...
phase1-wor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5463efcfc2 | ||
|
|
3fdbeed813 | ||
|
|
a18ef6c405 | ||
|
|
eae6a471e8 | ||
|
|
a61bd05a5c | ||
|
|
8109b3ec76 | ||
|
|
9d78bc4317 | ||
|
|
5c3c979f77 | ||
|
|
b52fe01d07 | ||
|
|
81da9bf71c | ||
|
|
1d04ac8cb7 | ||
|
|
27465cfeac | ||
|
|
fb5da1e3cd | ||
|
|
f661724c72 | ||
|
|
d127e19b45 | ||
|
|
d022e529d9 |
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Faxes',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.1.1',
|
||||
'category': 'Productivity',
|
||||
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
||||
'description': """
|
||||
|
||||
@@ -32,5 +32,13 @@
|
||||
<field name="value"></field>
|
||||
</record>
|
||||
|
||||
<!-- UI toggle — when False, hides the "Send Fax" header button
|
||||
on sale orders and invoices. Smart "Faxes" button (count
|
||||
badge) is unaffected. -->
|
||||
<record id="config_show_send_fax_button" model="ir.config_parameter">
|
||||
<field name="key">fusion_faxes.show_send_fax_button</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -17,12 +17,26 @@ class AccountMove(models.Model):
|
||||
string='Fax Count',
|
||||
compute='_compute_fax_count',
|
||||
)
|
||||
x_ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show Send Fax Button',
|
||||
compute='_compute_show_send_fax_button',
|
||||
help='Driven by the Settings toggle '
|
||||
'(fusion_faxes.show_send_fax_button).',
|
||||
)
|
||||
|
||||
@api.depends('x_ff_fax_ids')
|
||||
def _compute_fax_count(self):
|
||||
for move in self:
|
||||
move.x_ff_fax_count = len(move.x_ff_fax_ids)
|
||||
|
||||
def _compute_show_send_fax_button(self):
|
||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_faxes.show_send_fax_button', 'True',
|
||||
)
|
||||
show = str(param).lower() not in ('false', '0', '')
|
||||
for move in self:
|
||||
move.x_ff_show_send_fax_button = show
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this invoice."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
string='Enable RingCentral Faxing',
|
||||
config_parameter='fusion_faxes.ringcentral_enabled',
|
||||
)
|
||||
ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show "Send Fax" Button on Sale Orders & Invoices',
|
||||
config_parameter='fusion_faxes.show_send_fax_button',
|
||||
default=True,
|
||||
help='When enabled, the "Send Fax" header button appears on '
|
||||
'sale order and invoice forms (for users in the Fax User '
|
||||
'group). Turn off to hide the button without removing '
|
||||
'fax-user access.',
|
||||
)
|
||||
ff_ringcentral_server_url = fields.Char(
|
||||
string='RingCentral Server URL',
|
||||
config_parameter='fusion_faxes.ringcentral_server_url',
|
||||
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
}
|
||||
|
||||
def set_values(self):
|
||||
"""Protect credential fields from being blanked accidentally."""
|
||||
"""Protect credential fields from being blanked accidentally
|
||||
and force-persist the Send Fax Boolean.
|
||||
|
||||
Odoo's stock ``set_param`` removes the row when a Boolean
|
||||
config_parameter is False, which makes the ``get_param``
|
||||
fallback default kick in — toggling OFF then would silently
|
||||
re-show the button. We bypass that by writing 'True' / 'False'
|
||||
as a string after super() runs so the row always exists.
|
||||
"""
|
||||
protected_keys = [
|
||||
'fusion_faxes.ringcentral_client_id',
|
||||
'fusion_faxes.ringcentral_client_secret',
|
||||
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
|
||||
existing = ICP.get_param(key, '')
|
||||
if existing:
|
||||
ICP.set_param(key, existing)
|
||||
return super().set_values()
|
||||
res = super().set_values()
|
||||
ICP.set_param(
|
||||
'fusion_faxes.show_send_fax_button',
|
||||
'True' if self.ff_show_send_fax_button else 'False',
|
||||
)
|
||||
return res
|
||||
|
||||
@@ -17,12 +17,28 @@ class SaleOrder(models.Model):
|
||||
string='Fax Count',
|
||||
compute='_compute_fax_count',
|
||||
)
|
||||
x_ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show Send Fax Button',
|
||||
compute='_compute_show_send_fax_button',
|
||||
help='Driven by the Settings toggle '
|
||||
'(fusion_faxes.show_send_fax_button). Default True for '
|
||||
'back-compat — the button stays visible until a manager '
|
||||
'turns it off.',
|
||||
)
|
||||
|
||||
@api.depends('x_ff_fax_ids')
|
||||
def _compute_fax_count(self):
|
||||
for order in self:
|
||||
order.x_ff_fax_count = len(order.x_ff_fax_ids)
|
||||
|
||||
def _compute_show_send_fax_button(self):
|
||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_faxes.show_send_fax_button', 'True',
|
||||
)
|
||||
show = str(param).lower() not in ('false', '0', '')
|
||||
for order in self:
|
||||
order.x_ff_show_send_fax_button = show
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this sale order."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"
|
||||
invisible="not x_ff_show_send_fax_button"
|
||||
groups="fusion_faxes.group_fax_user"/>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show "Send Fax" button toggle -->
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ff_show_send_fax_button"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ff_show_send_fax_button"/>
|
||||
<div class="text-muted">
|
||||
Show the "Send Fax" header button on sale orders and invoices.
|
||||
Turn off to hide the button without removing fax-user access.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server URL -->
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
invisible="not ff_ringcentral_enabled">
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"
|
||||
invisible="not x_ff_show_send_fax_button"
|
||||
groups="fusion_faxes.group_fax_user"/>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
|
||||
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
|
||||
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
|
||||
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. | `fusion_plating_reports`, `report.paperformat` |
|
||||
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. **For landscape custom-header reports, reuse `paperformat_fp_a4_landscape_compact`** (same shape, just rotated) — invoice landscape uses this; don't create yet-another-landscape-compact. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. (4) **XML comments cannot contain `--` (double-hyphen)** per the XML spec — `<!-- needs wkhtmltopdf --footer-html -->` fails with `XMLSyntaxError: Comment must not contain '--' (double-hyphen)`. Rewrite without the double-hyphen: `<!-- needs a wkhtmltopdf footer-html arg -->`. Bites when documenting CLI flags or option names in QWeb comments. | `fusion_plating_reports`, `report.paperformat` |
|
||||
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
|
||||
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
|
||||
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
|
||||
@@ -44,6 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
|
||||
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
|
||||
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
||||
# Shop Floor Tablet Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Status:** Brainstorm complete, awaiting user review
|
||||
**Authors:** Garry Singh + Claude
|
||||
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
|
||||
**Target client:** EN Technologies (Fusion Plating)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
The current Shop Floor tablet view (client action `fp_shopfloor_tablet`, OWL component `ShopfloorTablet`) was built during the initial Fusion Plating implementation. Since then the underlying models — `fp.job`, `fp.job.step`, `fp.job.workflow.state`, `fp.certificate`, `fp.thickness.reading`, `fp.job.consumption`, `fp.job.node.override`, `fp.racking.inspection` and friends — have grown substantially. Many of those new fields, actions, and workflows are not surfaced on the tablet.
|
||||
|
||||
Symptoms observed on a live development instance:
|
||||
- Step name shows "Active: Blasting" with no WO/customer context
|
||||
- Qty rendered as "Qty 17/1" — ambiguous direction
|
||||
- Step position shown as "step 1.1/11" (sequence divided by 10)
|
||||
- Active timer reading **411:52:16** — a stale start that never finished and was never auto-paused
|
||||
- "SIGN-OFF REQUIRED" chip is informational only; no actual sign-off control
|
||||
- Customer spec, drawings, recipe overrides, milestone progress, holds, and most lifecycle actions are invisible
|
||||
|
||||
Three roles operate the system: **Owner**, **Manager**, **Technician**. Technicians wear multiple hats (receiving, plating, QC, shipping) — the client explicitly wants minimal gating between roles.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- A technician can manage a WO end-to-end from a single full-screen workspace, without typing into search bars or jumping to the back-office.
|
||||
- A manager can see at a glance: where every WO is in the workflow, what needs their decision right now, what's trending late, and where the bottlenecks are.
|
||||
- All recent additions to `fp.job` / `fp.job.step` (workflow milestones, blocker reasons, recipe overrides, customer spec, etc.) are surfaced on the tablet and manager dashboard.
|
||||
- Terminology matches how techs talk on the shop floor — "WO # 00001" not "WH/JOB/00001".
|
||||
- The system never displays a 411-hour ghost timer.
|
||||
|
||||
## 3. Non-goals (v1)
|
||||
|
||||
- Multi-tablet pairing per technician
|
||||
- Offline-first / PWA mode
|
||||
- Voice input
|
||||
- Performance optimization beyond ~500 active jobs (current scale: ~50)
|
||||
- Webhooks to external dashboards
|
||||
- Cost roll-up per job, cycle time per recipe, per-tech throughput (P2 — deferred to v2)
|
||||
- System-wide rename of `fp.job` sequence (`WH/JOB/...` → `WO ...`) — display-only on tablet for now; back-office/reports/emails keep current sequence until a separate decision is made
|
||||
|
||||
## 4. Terminology decisions
|
||||
|
||||
All approved in brainstorm:
|
||||
|
||||
| Element | Was | Is now |
|
||||
|---|---|---|
|
||||
| Page title | "Tablet Station" | **Shop Floor** (with station chip e.g. "@ EN Plating Tank") |
|
||||
| Document number | `WH/JOB/00001` | **`WO # 00001`** (display only) |
|
||||
| Active step header | "Active: Blasting" | **"WO # 00001 — Blasting"** (Step 1 of 11) |
|
||||
| Qty | "Qty 17/1" | **"1 / 17 done"** + scrap subtext + mini progress bar |
|
||||
| Step position | "step 1.1/11" | **"Step 1 of 11"** |
|
||||
| Sign-off chip | "SIGN-OFF REQUIRED" | **"Finish & Sign Off"** action button (replaces plain Finish) |
|
||||
| Queue heading | "My Queue" | **"Up Next"** (at this station) |
|
||||
| Bath state | "OPERATIONAL / LOG: OUT_OF_SPEC" | **"Operating"** / **"Last log out of spec"** |
|
||||
| Bake panel | "Bake Windows" | **"Embrittlement Bakes"** |
|
||||
| Gate panel | "First-Piece Gates" | **"First-Piece Inspections"** |
|
||||
| Tile set | 6 mixed tiles | **4 tech-relevant tiles**: Ready · Running · Bakes Due · Holds (others move to manager dashboard) |
|
||||
| Stale timer | "411:52:16" | Auto-pause at 8h (configurable) with chatter audit; display switches to "Started Nd ago" past 24h |
|
||||
|
||||
## 5. Architecture — option B (specialized components + shared services)
|
||||
|
||||
Three OWL client actions, five shared OWL services, a small set of backend additions. Each client action is independently deployable.
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐ ┌────────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ fp_shopfloor_landing │ │ fp_job_workspace │ │ fp_manager_dashboard │
|
||||
│ (replaces fp_shopfloor_tablet │ │ NEW — full-screen WO surface │ │ refactored — 4 tabs │
|
||||
│ + folds in fp_plant_overview)│ │ │ │ │
|
||||
│ • station-scoped kanban │ │ • sticky header + WO chips │ │ • Workflow Funnel (default) │
|
||||
│ • All-Plant toggle │ │ • workflow milestone bar │ │ • Approval Inbox │
|
||||
│ • QR scan, station picker │ │ • step list + side panel │ │ • Plant Board (existing) │
|
||||
│ • tap card → JobWorkspace │ │ • sticky action rail │ │ • At-Risk │
|
||||
└────────────────────────────────┘ └────────────────────────────────┘ └────────────────────────────────┘
|
||||
│ │ │
|
||||
└──────────────────────────────────┴──────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Shared OWL services │
|
||||
│ WorkflowChip · GateViz │
|
||||
│ SignaturePad · KanbanCard │
|
||||
│ HoldComposer │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.1 Shared OWL services
|
||||
|
||||
| Service | Used by | Props | Depends on |
|
||||
|---|---|---|---|
|
||||
| **WorkflowChip** | Landing card · Workspace header · Manager funnel | `{ state: {id, name, color}, nextActionLabel? }` | `fp.job.workflow.state` records (already shipped). Reads `color` field. |
|
||||
| **GateViz** | Workspace step rows · Manager "Needs Worker" cards | `{ canStart, blockerKind, blockerReason, jumpTarget? }` | New `fp.job.step.blocker_kind` + `blocker_reason` computes |
|
||||
| **SignaturePad** | Workspace (Finish & Sign Off) · Cert issue | `{ title, contextLabel, onSubmit(dataUri), onCancel }` | Odoo `dialog` service; HTML canvas + pointer events |
|
||||
| **HoldComposer** | Workspace (Hold button) · Manager Approval Inbox | `{ jobId, stepId?, defaultQty, partRef, onCreated(hold) }` | New endpoint `/fp/workspace/hold` (with photo attachment) |
|
||||
| **KanbanCard** | Landing (station + all-plant) · Manager (Plant Board + Workflow Funnel) | `{ data, density: 'compact'\|'normal', showWorkflowChip, showWorkcenter, showAssignedTo, onTap }` | Embeds `WorkflowChip` + `GateViz` badge |
|
||||
|
||||
Each service is its own file under `fusion_plating_shopfloor/static/src/js/components/`. Roughly 80–200 lines OWL + 30–80 lines SCSS per service.
|
||||
|
||||
### 5.2 Landing component (`fp_shopfloor_landing`)
|
||||
|
||||
Replaces today's `fp_shopfloor_tablet`, folds in `fp_plant_overview`. Single entry surface for technicians.
|
||||
|
||||
**Layout regions** (top-to-bottom):
|
||||
|
||||
1. **Header strip** — "Shop Floor" title, station chip, station picker, mode toggle (`Station` ⟷ `All Plant`), QR scan controls (Code + Camera), refresh indicator.
|
||||
2. **KPI tile row (4 tiles)** — Ready · Running · Bakes Due · Holds. Holds turns red when > 0.
|
||||
3. **Kanban board** — columns = work centres; cards = `KanbanCard` (one per WO at that work centre); urgency-sorted within column (existing logic in `plant_overview.py` carries over). Drag-and-drop between columns keeps current behaviour.
|
||||
4. **Optional left filter rail** (collapsed by default) — search box, priority, customer, due-by, blocker filter. Promote the existing plant_overview search bar.
|
||||
5. **Footer** — auto-refresh indicator + "Last sync HH:MM:SS".
|
||||
|
||||
**Mode behaviour:**
|
||||
|
||||
| Mode | Columns shown | Default when |
|
||||
|---|---|---|
|
||||
| **Station** | Paired work centre + Unassigned + next 1–2 work centres in the recipe flow | A station is paired (via QR scan or picker) |
|
||||
| **All Plant** | Every active work centre, recipe-flow order | No station paired, OR user toggles |
|
||||
|
||||
Toggle persists in `localStorage` per tablet (same pattern as `fp_tablet_station_id`).
|
||||
|
||||
**Card tap behaviour:**
|
||||
|
||||
```js
|
||||
action.doAction({
|
||||
type: 'ir.actions.client',
|
||||
tag: 'fp_job_workspace',
|
||||
params: { job_id: card.job_id, focus_step_id: card.current_step_id }
|
||||
});
|
||||
```
|
||||
|
||||
Browser back returns to Landing with kanban scroll/mode preserved.
|
||||
|
||||
**QR scan dispatch** (existing `/fp/shopfloor/scan` endpoint, unchanged):
|
||||
|
||||
| Scanned | Behaviour |
|
||||
|---|---|
|
||||
| `FP-STATION:<code>` | Pair tablet, switch to Station mode |
|
||||
| `FP-JOB:<name>` | Open JobWorkspace for that WO |
|
||||
| `FP-STEP:<id>` | Open JobWorkspace, focus that step |
|
||||
| `FP-TANK:<code>` / `FP-BATH:<name>` | Chemistry quick-log dialog (existing endpoint) |
|
||||
| `FP-OVEN:<code>` | Jump to next bake awaiting that oven |
|
||||
|
||||
**Auto-refresh** — every 15s.
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
fusion_plating_shopfloor/
|
||||
controllers/landing_controller.py ← NEW (~250 lines)
|
||||
static/src/js/shopfloor_landing.js ← OWL (~600 lines)
|
||||
static/src/xml/shopfloor_landing.xml
|
||||
static/src/scss/shopfloor_landing.scss
|
||||
```
|
||||
|
||||
### 5.3 Job Workspace component (`fp_job_workspace`)
|
||||
|
||||
The heart of the redesign. Full-screen surface a tech opens by tapping a kanban card.
|
||||
|
||||
**Layout regions** (sticky top → scrollable middle → sticky bottom):
|
||||
|
||||
| Region | Sticky | Data | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **Back** | top | — | `doAction` back to Landing, preserves kanban scroll/mode |
|
||||
| **WO header** | top | `display_wo_name`, `partner_id`, `part_catalog_id` + rev, qty/qty_done/qty_scrapped, `date_deadline`, `workflow_state_id`, `quality_hold_count`, `customer_spec_id` | `+1 Done` / `−1 Done` / `+1 Scrap` quick bumps inline. Holds count → opens Holds drawer. |
|
||||
| **Workflow milestone bar** | top | All `fp.job.workflow.state` records ordered by sequence; current = `workflow_state_id`; `next_milestone_action` + `next_milestone_label` | Dots: passed (●), current (filled), pending (○). "Next" button on right fires `/fp/workspace/advance_milestone`. Disabled until preconditions met. |
|
||||
| **Step list** (left/center, scrolls) | scrolls | `fp.job.step_ids` sorted by `sequence` | Each row uses step-row template (see below). Active step auto-scrolled and auto-expanded if `focus_step_id` param set. |
|
||||
| **Side panel** (collapsible right) | scrolls | `customer_spec_id` (PDF), attachments, chatter | Three sub-cards: **Spec** (inline via `fusion_pdf_preview`), **Drawings**, **Notes** (chatter — read + quick-add). Collapses to icon strip on narrow screens. |
|
||||
| **Action rail** | bottom | — | Always: Create Hold (`HoldComposer`), Add Note, Photo. Conditional: Issue Cert (when `_fp_has_draft_required_certs()`), Mark Done / Schedule Delivery / Mark Shipped (per `next_milestone_action`). |
|
||||
|
||||
**Step row anatomy:**
|
||||
|
||||
- **Collapsed** (default for done/pending/paused): one line — icon + Step N · Name + assigned tech + duration + state badge
|
||||
- **Expanded active** (auto for `state == 'in_progress'`): recipe chips + instructions + primary + secondary actions
|
||||
- **Expanded by tap** (any step): same shape, action buttons gated by `can_start`
|
||||
|
||||
**Per-step actions:**
|
||||
|
||||
| Button | Visible when | Calls |
|
||||
|---|---|---|
|
||||
| Start | `state in ('ready','paused')` AND `can_start` | `/fp/shopfloor/start_wo` |
|
||||
| Finish (or Finish & Sign Off) | `state == 'in_progress'` | `/fp/shopfloor/stop_wo` (finish=true) OR `/fp/workspace/sign_off` if `requires_signoff` |
|
||||
| Pause | `state == 'in_progress'` | `step.button_pause()` |
|
||||
| Skip | `state in ('ready','paused')` AND user is supervisor+ | `step.button_skip()` |
|
||||
| Move Parts | always | `FpMovePartsDialog` (existing) |
|
||||
| Move Rack | when `kind == 'rack'` | `FpMoveRackDialog` (existing) |
|
||||
| Quick QC | when `quick_look_prompt_ids` non-empty | `step.action_open_quick_look()` |
|
||||
| Operator Inputs | when step has unrecorded inputs | `step.action_open_input_wizard()` (existing wizard) |
|
||||
| Photo | always | inline camera → attach to step |
|
||||
| Stop Timer (correction) | when duration looks wrong | `FpStopTimerDialog` (existing) |
|
||||
| Open in backend | always (small icon) | `doAction` to fp.job.step form (escape hatch) |
|
||||
|
||||
When step is **blocked** (`can_start == False`), action button row is replaced by `GateViz` block.
|
||||
|
||||
When step is **opted out** (`override_ids` says excluded), row shows ✕ icon + "Skipped per recipe override" + supervisor-only "Re-include" button.
|
||||
|
||||
**Auto-pause integration** — if `_cron_autopause_stale_steps` flips a step to paused, the row's chatter reflects "Auto-paused after Nh idle". Tech can tap Resume.
|
||||
|
||||
**Auto-refresh** — every 15s.
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
fusion_plating_shopfloor/
|
||||
controllers/workspace_controller.py ← NEW (~400 lines)
|
||||
static/src/js/job_workspace.js ← OWL (~800 lines)
|
||||
static/src/xml/job_workspace.xml
|
||||
static/src/scss/job_workspace.scss
|
||||
static/src/js/components/{workflow_chip,gate_viz,signature_pad,hold_composer,kanban_card}.js
|
||||
```
|
||||
|
||||
### 5.4 Manager Dashboard refactor (`fp_manager_dashboard`)
|
||||
|
||||
Same client action, **four sibling tabs** under a shared header + KPI strip.
|
||||
|
||||
**KPI strip (extended):** keep existing 4 always-on (Unassigned Steps · In Progress · Ready to Ship · Awaiting Assignment) + existing conditional reds (Missed Bakes · Open Holds · Stale Steps · Predecessor Locked) + **2 new: Pending Cert · At-Risk**.
|
||||
|
||||
**Tabs:**
|
||||
|
||||
| Tab | Default? | Content |
|
||||
|---|---|---|
|
||||
| **Workflow Funnel** | yes | Vertical stack of `fp.job.workflow.state` records. Each row shows stage chip + count badge + first ~5 `KanbanCard`s + "+ N more" drawer. Bar chart bar behind the row scaled to count. Tap card → JobWorkspace. |
|
||||
| **Approval Inbox** | no | 4 grouped strips: **Holds to Release** (`state in ('on_hold','under_review')`), **Certs to Issue** (`all_steps_terminal` + draft required cert), **Scrap to Review** (recent `qty_scrapped` bumps with operator reason), **Override Requests** (deferred — placeholder). Per-row inline action buttons + bulk-action ("Release all"). |
|
||||
| **Plant Board** | no | Today's existing 3-column "Needs Worker / In Progress / Team" view — unchanged behaviour. Becomes one tab among four. |
|
||||
| **At-Risk** | no | 3 sub-panels: **Trending Late** (sorted by `late_risk_ratio` desc, top 20), **Hold Reasons** (open holds grouped by `hold_reason`), **Bottleneck Heatmap** (work centres ranked by `bottleneck_score`). |
|
||||
|
||||
**Cross-tab features:** live 8s refresh (existing cadence), QR scan in header, "Take Over Tablet" supervisor handover.
|
||||
|
||||
**Permissions:** dashboard already gated to `group_fusion_plating_supervisor`+. That stays. Owner + Manager hit this; Technicians don't.
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
fusion_plating_shopfloor/
|
||||
controllers/manager_controller.py ← add 3 endpoints (funnel, approval_inbox, at_risk)
|
||||
static/src/js/manager_dashboard.js ← refactor: extract Plant Board, add 3 sibling tabs
|
||||
static/src/xml/manager_dashboard.xml
|
||||
static/src/scss/manager_dashboard.scss
|
||||
```
|
||||
|
||||
## 6. Backend support
|
||||
|
||||
### 6.1 HTTP endpoints
|
||||
|
||||
All `type='jsonrpc'`, `auth='user'`.
|
||||
|
||||
**NEW** — added by this work:
|
||||
|
||||
| Endpoint | Lives in | Purpose |
|
||||
|---|---|---|
|
||||
| `POST /fp/landing/kanban` | `landing_controller.py` | Station OR all-plant kanban data |
|
||||
| `POST /fp/workspace/load` | `workspace_controller.py` | Full Job Workspace payload |
|
||||
| `POST /fp/workspace/hold` | `workspace_controller.py` | HoldComposer create (with photo) |
|
||||
| `POST /fp/workspace/sign_off` | `workspace_controller.py` | Signature + finish step atomically |
|
||||
| `POST /fp/workspace/advance_milestone` | `workspace_controller.py` | Fire `next_milestone_action` |
|
||||
| `POST /fp/manager/funnel` | `manager_controller.py` (add) | Workflow funnel data |
|
||||
| `POST /fp/manager/approval_inbox` | `manager_controller.py` (add) | Holds + draft certs + scrap to review |
|
||||
| `POST /fp/manager/at_risk` | `manager_controller.py` (add) | Late-risk + hold reasons + bottlenecks |
|
||||
|
||||
**KEPT** — unchanged, used by new components via wrappers: `/fp/shopfloor/scan`, `start_wo`, `stop_wo`, `start_bake`, `end_bake`, `log_chemistry`, `log_thickness_reading`, `bump_qty_done`, `bump_qty_scrapped`, `mark_gate`, `pair_station`.
|
||||
|
||||
**DEPRECATED** — kept as stubs for 1 release, then removed:
|
||||
- `/fp/shopfloor/tablet_overview` → calls `/fp/landing/kanban` internally
|
||||
- `/fp/shopfloor/plant_overview` → calls `/fp/landing/kanban?mode=all_plant`
|
||||
- `/fp/shopfloor/queue` → removed (no replacement)
|
||||
|
||||
### 6.2 Model fields / computes
|
||||
|
||||
On `fp.job` (`fusion_plating_jobs/models/fp_job.py`):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `display_wo_name` | computed Char | "WO # 00001" formatter from `name` |
|
||||
| `late_risk_ratio` | computed Float, stored | `remaining_planned_minutes / minutes_to_deadline` |
|
||||
| `active_step_id` | computed Many2one→fp.job.step | Current `in_progress` step (Workspace landing focus) |
|
||||
|
||||
On `fp.job.step` (`fusion_plating_jobs/models/fp_job_step.py`):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `blocker_kind` | computed Selection | `predecessor` · `contract_review` · `parts_not_received` · `racking_required` · `manager_input` · `none` |
|
||||
| `blocker_reason` | computed Char | Human reason (e.g. "Waiting on Step 3: Activation") |
|
||||
| `blocker_jump_target_model` | computed Char | Optional tap-to-jump target model |
|
||||
| `blocker_jump_target_id` | computed Integer | Optional tap-to-jump target id |
|
||||
|
||||
On `fp.work.centre` (`fusion_plating/models/fp_work_centre.py`):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `bottleneck_score` | computed Float, non-stored | `active_step_count × avg_wait_minutes` |
|
||||
| `avg_wait_minutes` | computed Float, non-stored | Rolling 7-day avg ready→start wait |
|
||||
|
||||
On `fusion.plating.process.node` (recipe node):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `long_running` | Boolean | Opt out of auto-pause (24h bakes etc.) |
|
||||
|
||||
### 6.3 Auto-pause cron
|
||||
|
||||
```xml
|
||||
<!-- fusion_plating_jobs/data/fp_cron_data.xml -->
|
||||
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
|
||||
<field name="name">FP Jobs: auto-pause stale in-progress steps</field>
|
||||
<field name="model_id" ref="model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_autopause_stale_steps()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
Method (`fp_job_step.py`):
|
||||
|
||||
```python
|
||||
@api.model
|
||||
def _cron_autopause_stale_steps(self):
|
||||
threshold = float(self.env['ir.config_parameter'].sudo()
|
||||
.get_param('fp.shopfloor.autopause_threshold_hours', 8))
|
||||
deadline = fields.Datetime.now() - timedelta(hours=threshold)
|
||||
stale = self.search([
|
||||
('state', '=', 'in_progress'),
|
||||
('date_started', '<', deadline),
|
||||
('recipe_node_id.long_running', '=', False),
|
||||
])
|
||||
for step in stale:
|
||||
step.button_pause()
|
||||
step.message_post(body=Markup(
|
||||
"<b>Auto-paused</b> after %.1fh idle. "
|
||||
"Resume from the tablet when work continues."
|
||||
) % threshold)
|
||||
_logger.info("Auto-paused step %s after %.1fh idle", step.id, threshold)
|
||||
```
|
||||
|
||||
`ir.config_parameter` key: `fp.shopfloor.autopause_threshold_hours` (default `8`).
|
||||
|
||||
### 6.4 ACL changes (operator group)
|
||||
|
||||
Per "techs wear multiple hats" rule — minimal new gates.
|
||||
|
||||
| Model | Read | Write | Create | Unlink | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `fp.certificate` | ✓ existing | **NEW ✓** | — | — | Flip draft → issued from tablet "Issue Cert" |
|
||||
| `fp.thickness.reading` | **NEW ✓** | **NEW ✓** | **NEW ✓** | — | Capture Fischerscope readings from tablet |
|
||||
| `fp.job.node.override` | **NEW ✓** | — | — | — | Read-only — tech sees opt-out badge |
|
||||
|
||||
Supervisor-only operations enforced in `workspace_controller.py` (not via ACL):
|
||||
- Step Skip (`button_skip`)
|
||||
- Hold Release (state transition `on_hold` → `released`)
|
||||
- Override Re-include
|
||||
|
||||
### 6.5 Terminology — `display_wo_name`
|
||||
|
||||
`fp.job.display_wo_name` is a computed Char that formats `name` as `WO # 00001`. All new tablet/dashboard payloads use this field. The underlying `fp.job.name` (`WH/JOB/00001`) stays unchanged — reports, emails, back-office forms continue using `name`.
|
||||
|
||||
System-wide sequence rename is **out of scope** for this work. If pursued separately, it requires: (a) updating `ir.sequence` prefix to `WO `, (b) backfill script for existing records, (c) coordination with any external integrations that grep on the old prefix.
|
||||
|
||||
## 7. Build & deploy sequence
|
||||
|
||||
Each phase is independently deployable. Rollback is per-phase, not all-or-nothing.
|
||||
|
||||
| Phase | Ships | Independently deployable? |
|
||||
|---|---|---|
|
||||
| **1** | Shared OWL services + JobWorkspace + workspace_controller + fp.job.step blocker_* computes + display_wo_name. Opens from existing `fp.job` form smart button. | Yes — works before Landing refactor |
|
||||
| **2** | Auto-pause cron + ACL lift + `late_risk_ratio` + `active_step_id` computes | Yes — silent infra |
|
||||
| **3** | landing_controller + `fp_shopfloor_landing` component. Old `fp_shopfloor_tablet` menu redirected. PlantOverview menu hidden. | Yes — Workspace already works via smart button |
|
||||
| **4** | 3 new manager endpoints + manager dashboard refactor (4 tabs) + `bottleneck_score` compute | Yes |
|
||||
| **5** | Cleanup: remove deprecated endpoint stubs, retire fp_plant_overview module dir | Last |
|
||||
|
||||
## 8. Testing strategy
|
||||
|
||||
### 8.1 Python tests (`fusion_plating_shopfloor/tests/`)
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_display_wo_name` | Formatter handles various `name` shapes |
|
||||
| `test_late_risk_ratio` | Correct ratio with deadline / no deadline / overdue / not started |
|
||||
| `test_active_step_id` | Sole in_progress step; empty when none; first-by-sequence when multiple |
|
||||
| `test_blocker_kind_and_reason` | Each kind returns correct enum + human string + jump target |
|
||||
| `test_autopause_cron` | Stale flips; chatter posted; respects `long_running`; idempotent |
|
||||
| `test_workspace_load_payload` | Full payload shape — keys, types, opted-out marked |
|
||||
| `test_workspace_sign_off` | Signature captured, step finished, empty-sig rejected |
|
||||
| `test_workspace_advance_milestone` | Fires only when preconditions met; friendly error otherwise |
|
||||
| `test_hold_composer_create` | Hold + photo + qty split; rollback on validation error |
|
||||
| `test_acl_operator_permissions` | Operator can issue cert, cannot skip step (controller gate) |
|
||||
| `test_funnel_and_inbox` | Funnel grouping correct; inbox returns all 4 buckets |
|
||||
|
||||
### 8.2 OWL tests (light)
|
||||
|
||||
- `WorkflowChip` renders correct color per state
|
||||
- `GateViz` renders correct copy per blocker_kind
|
||||
- `SignaturePad` returns non-empty data URI after stroke
|
||||
- `HoldComposer` validates qty ≤ remaining before submit
|
||||
- `KanbanCard` collapses chips at compact density
|
||||
|
||||
### 8.3 Manual QA checklist
|
||||
|
||||
Lives at `docs/qa/shopfloor-redesign-qa.md`. 10-step walkthrough covering: pairing → Landing modes → tap card → Workspace → Finish & Sign Off → Create Hold → Manager Funnel → Approval Inbox release → auto-pause test → dark mode.
|
||||
|
||||
## 9. Observability
|
||||
|
||||
- `_logger.INFO` on milestone advance, hold create from tablet, sign-off, auto-pause
|
||||
- `_logger.WARNING` on workspace_load with bad job_id, sign_off with empty data URI
|
||||
- `_logger.EXCEPTION` on controller failures (existing pattern)
|
||||
- Chatter audit on auto-pause, hold create, milestone advance, sign-off
|
||||
|
||||
Future metrics (flagged, no infra now): tablet refresh frequency, time-to-Start from Workspace open, auto-pause rate, hold creation rate.
|
||||
|
||||
## 10. Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Job has zero steps | "Recipe not generated" placeholder + back-office link |
|
||||
| Job has 50+ steps | Standard scroll, no virtualization in v1 |
|
||||
| All steps in_progress (defensive) | `active_step_id` picks first by sequence; logs warning |
|
||||
| No workflow states defined | Bar hides; Next button disabled |
|
||||
| Step state changed during 15s gap | Refresh corrects; no error toast |
|
||||
| Two techs tap Start simultaneously | `button_start` idempotent; second call returns state |
|
||||
| Wrong station scanned | Header "Unpair" link; localStorage cleared |
|
||||
| Network drop mid-action | Toast "Saving failed — tap to retry"; UI state preserved |
|
||||
| 24h-bake step | `recipe_node.long_running=True` skips auto-pause |
|
||||
| Customer spec PDF missing | Side panel: "No customer spec attached" |
|
||||
| Funnel with 200+ jobs in stage | Top 5 cards + "View all (N)" drawer |
|
||||
| Operator with no facility | Landing prompts pick-or-scan station |
|
||||
| HoldComposer fails after photo upload | Photo cleaned in `except` block — no orphans |
|
||||
| Manual step (no recipe_node_id) | Chips/instructions empty — OK |
|
||||
| Sign-off step finished from back-office | Workspace re-renders without re-prompting |
|
||||
| Tech opens Workspace for unassigned job | Allowed — read+write per "many hats" rule |
|
||||
|
||||
## 11. Performance
|
||||
|
||||
| Surface | Load shape | Mitigation |
|
||||
|---|---|---|
|
||||
| Landing kanban (All Plant, ~400 cards) | Existing `plant_overview.py` batch prefetch carries over | None new |
|
||||
| Workspace load (1 job × ~11 steps) | Trivial | None |
|
||||
| Manager funnel (~50 jobs × 9 stages) | Trivial | None |
|
||||
| Manager At-Risk (7-day step state scan) | Potentially heavy on large plants | Cache 60s per facility |
|
||||
| Auto-pause cron | Filtered query, no N+1 | None |
|
||||
| `late_risk_ratio` (stored) | Recomputed on step state change | `@api.depends` triggers |
|
||||
|
||||
## 12. Rollback strategy
|
||||
|
||||
| Phase | Rollback |
|
||||
|---|---|
|
||||
| 1 (Workspace) | Hide smart-button entry in fp.job form. Workspace becomes orphan, harmless. |
|
||||
| 2 (Cron + ACL) | Disable cron via UI. ACL changes are CSV-line edits. |
|
||||
| 3 (Landing) | Re-enable old `fp_shopfloor_tablet` menu. Old endpoint stub still active. |
|
||||
| 4 (Manager) | Revert manager_dashboard.xml to single Plant Board tab. |
|
||||
| 5 (Cleanup) | Defer if issues — leave stubs longer. |
|
||||
|
||||
Only stored field added is `late_risk_ratio` Float — additive `ALTER TABLE`, safe to drop.
|
||||
|
||||
## 13. Backwards compatibility
|
||||
|
||||
- All existing QR codes keep working — `/fp/shopfloor/scan` unchanged
|
||||
- Existing `fp.job` form smart buttons → Workspace opens via doAction
|
||||
- Existing `fp.job.step` form view stays as "Open in backend" escape hatch
|
||||
- Old reports/emails keep showing `WH/JOB/00001` (sequence rename deferred)
|
||||
|
||||
## 14. Decisions log
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Hybrid mental model (queue → workspace), not pure queue or pure job-first | Queue is the natural entry; full workspace solves "manage the whole job" goal |
|
||||
| One tablet, no role-segmentation per persona | Client said techs wear multiple hats; minimal gating |
|
||||
| Architecture B (specialized components + shared services) over mega-component | Each surface has its own lifecycle; shared services enforce consistency |
|
||||
| Station-scoped kanban as default landing, All Plant as toggle | Matches physical reality (tablet at station) + provides escape hatch |
|
||||
| Approval Inbox + Workflow Funnel as new manager tabs, Plant Board stays | Tactical (assignment) and strategic (where is everything) views coexist |
|
||||
| Auto-pause stale timers at 8h default | Solves the 411-hour ghost timer permanently; protects cost/cycle-time math |
|
||||
| WO # display only; sequence rename deferred | Lower risk; user can choose system-wide rename separately |
|
||||
| ACL lift for operator group on cert / thickness reading / override read | Per "techs wear many hats" rule; supervisor-only ops enforced in controller, not ACL |
|
||||
|
||||
## 15. Out of scope for v1
|
||||
|
||||
- Multi-tablet pairing per tech
|
||||
- Offline-first / PWA mode
|
||||
- Voice input
|
||||
- Performance optimisation beyond ~500 active jobs
|
||||
- Webhooks to external dashboards
|
||||
- Cost roll-up per job (P2)
|
||||
- Cycle time per recipe (P2)
|
||||
- Notification audit feed (P2)
|
||||
- Per-tech throughput (P2)
|
||||
- System-wide `fp.job` sequence rename to `WO ` prefix
|
||||
|
||||
---
|
||||
|
||||
**Next step:** user reviews this spec; once approved, transition to `superpowers:writing-plans` skill to produce the phased implementation plan.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.5.5',
|
||||
'version': '19.0.21.7.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -56,10 +56,12 @@ Provides:
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'views/fp_so_job_sort_views.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
||||
@@ -72,6 +74,13 @@ Provides:
|
||||
'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
|
||||
],
|
||||
# Register the Job Status pill SCSS in both bundles so the
|
||||
# `@if $o-webclient-color-scheme == dark` branch compiles for
|
||||
# the dark variant (see CLAUDE.md "Dark Mode" — Odoo 19 has no
|
||||
# runtime DOM toggle, two pre-built bundles).
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import fp_part_catalog
|
||||
from . import fp_pricing_complexity_surcharge
|
||||
from . import fp_pricing_rule
|
||||
from . import fp_sale_description_template
|
||||
from . import fp_so_job_sort
|
||||
from . import fp_quote_configurator
|
||||
from . import fp_serial
|
||||
from . import sale_order
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpSoJobSort(models.Model):
|
||||
"""A user-defined grouping bucket for sale orders ("Job Sorting").
|
||||
|
||||
Same pattern as `fusion.plating.tank.section` — every shop slices its
|
||||
SO backlog differently (by customer programme, by priority, by
|
||||
fabricator group, by week, etc.). Sections are free-form, renameable,
|
||||
quick-creatable from the M2O dropdown, and let users group the SO
|
||||
list with fold/expand sections.
|
||||
"""
|
||||
_name = 'fp.so.job.sort'
|
||||
_description = 'Fusion Plating — Sale Order Job Sort'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Job Sorting',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
color = fields.Integer(string='Color', default=0)
|
||||
fold = fields.Boolean(
|
||||
string='Folded by Default',
|
||||
help='When set, this section appears collapsed in the grouped '
|
||||
'SO list so the body rows are hidden until expanded.',
|
||||
)
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
sale_order_ids = fields.One2many(
|
||||
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
|
||||
)
|
||||
sale_order_count = fields.Integer(
|
||||
compute='_compute_sale_order_count',
|
||||
)
|
||||
|
||||
@api.depends('sale_order_ids')
|
||||
def _compute_sale_order_count(self):
|
||||
for rec in self:
|
||||
rec.sale_order_count = len(rec.sale_order_ids)
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_job_sort_id', '=', self.id)],
|
||||
'context': {'default_x_fc_job_sort_id': self.id},
|
||||
}
|
||||
@@ -67,6 +67,28 @@ class SaleOrder(models.Model):
|
||||
'Net Terms strategies.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
||||
|
||||
# Lead Time (Phase D11) — promised production window in business
|
||||
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
|
||||
# so we render a proper expectation on the SO confirmation instead
|
||||
# of the binary Standard/Rush we had before. Both fields default to
|
||||
# 0 — `x_fc_lead_time_display` computes the right human-readable
|
||||
# string (range / single value / Rush / Standard) for the PDF.
|
||||
x_fc_lead_time_min_days = fields.Integer(
|
||||
string='Lead Time Min (days)', tracking=True,
|
||||
help='Lower bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_max_days = fields.Integer(
|
||||
string='Lead Time Max (days)', tracking=True,
|
||||
help='Upper bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_display = fields.Char(
|
||||
string='Lead Time',
|
||||
compute='_compute_lead_time_display',
|
||||
help='Human-readable lead time string for the SO confirmation PDF.',
|
||||
)
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
@@ -88,6 +110,16 @@ class SaleOrder(models.Model):
|
||||
help="Customer's internal job number for cross-referencing.",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
help='Free-form bucket that groups this SO in the "Sale Orders '
|
||||
'by Sorting" list view. Quick-create from the dropdown — '
|
||||
'each shop slices its backlog differently (customer programme, '
|
||||
'priority, week, etc.).',
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
string='Planned Start Date', tracking=True,
|
||||
)
|
||||
@@ -129,6 +161,16 @@ class SaleOrder(models.Model):
|
||||
string='Deadline',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
# Drives the colour of the Deadline column. Computed in the same pass
|
||||
# as x_fc_deadline_countdown so the buckets always agree with the
|
||||
# human-readable countdown string.
|
||||
x_fc_deadline_urgency = fields.Selection(
|
||||
[('overdue', 'Overdue'),
|
||||
('urgent', 'Due within 2 days'),
|
||||
('safe', 'More than 2 days')],
|
||||
string='Deadline Urgency',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
x_fc_order_completion_date = fields.Date(
|
||||
string='Order Completion Date',
|
||||
compute='_compute_order_completion_date',
|
||||
@@ -241,6 +283,157 @@ class SaleOrder(models.Model):
|
||||
compute='_compute_invoiced_amount',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
# Single "Job Status" pill rendered in the SO list. Pipeline order:
|
||||
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
|
||||
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
|
||||
# Delivered → Invoiced → Paid → Cancelled.
|
||||
# Rendered as an Html field so each kind can carry its own tint via
|
||||
# an .fp-kind-* class — Bootstrap's 5 decoration-* slots aren't
|
||||
# enough to give every phase a distinct colour. SCSS bundle at
|
||||
# static/src/scss/fp_job_status_pill.scss owns the colour map.
|
||||
x_fc_fp_job_status = fields.Html(
|
||||
string='Job Status',
|
||||
compute='_compute_fp_job_status',
|
||||
sanitize=False,
|
||||
help='Single at-a-glance pill that advances through the order '
|
||||
'lifecycle: receiving → WO progress → shipping → invoicing.',
|
||||
)
|
||||
x_fc_fp_job_status_kind = fields.Selection(
|
||||
[('muted', 'Draft (grey)'),
|
||||
('warning', 'Awaiting / Partial (amber)'),
|
||||
('primary', 'Ready / Milestone (purple)'),
|
||||
('info', 'Active Work (blue)'),
|
||||
('shipping', 'Shipping (cyan)'),
|
||||
('delivered', 'Delivered (teal)'),
|
||||
('invoiced', 'Invoiced (lime)'),
|
||||
('paid', 'Paid (green bold)'),
|
||||
('danger', 'Cancelled (red)')],
|
||||
string='Job Status Kind',
|
||||
compute='_compute_fp_job_status',
|
||||
help='Colour category that backs the Job Status pill — also '
|
||||
'usable for filtering / grouping in the list search panel.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'x_fc_receiving_status',
|
||||
'x_fc_wo_completion',
|
||||
'invoice_ids.state',
|
||||
'invoice_ids.payment_state',
|
||||
'invoice_ids.move_type',
|
||||
)
|
||||
def _compute_fp_job_status(self):
|
||||
from markupsafe import Markup as _Markup
|
||||
from markupsafe import escape as _escape
|
||||
for so in self:
|
||||
label, kind = self._fp_resolve_job_status(so)
|
||||
so.x_fc_fp_job_status_kind = kind
|
||||
so.x_fc_fp_job_status = _Markup(
|
||||
'<span class="fp-job-status fp-kind-%s">%s</span>'
|
||||
) % (_Markup(kind), _escape(label))
|
||||
|
||||
@staticmethod
|
||||
def _fp_resolve_job_status(so):
|
||||
# Terminal SO states first.
|
||||
if so.state == 'cancel':
|
||||
return ('Cancelled', 'danger')
|
||||
if so.state in ('draft', 'sent'):
|
||||
return ('Draft', 'muted')
|
||||
|
||||
# Invoice phase (terminal positive states).
|
||||
posted = so.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted'
|
||||
and m.move_type in ('out_invoice', 'out_refund')
|
||||
)
|
||||
if posted and all(
|
||||
m.payment_state in ('paid', 'in_payment') for m in posted
|
||||
):
|
||||
return ('Paid', 'paid')
|
||||
|
||||
# Shipping phase signals — read once.
|
||||
ship_status = None
|
||||
if 'x_fc_receiving_ids' in so._fields:
|
||||
for r in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
r.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in r._fields else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
# Latch the most-advanced status across all receivings.
|
||||
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
|
||||
cur = (
|
||||
'delivered' if ship.status == 'delivered'
|
||||
else 'in_transit' if ship.status == 'shipped'
|
||||
else 'booked' if ship.status in ('confirmed', 'draft')
|
||||
else None
|
||||
)
|
||||
if rank[cur] > rank[ship_status]:
|
||||
ship_status = cur
|
||||
|
||||
if posted and ship_status == 'delivered':
|
||||
return ('Invoiced', 'invoiced')
|
||||
if ship_status == 'delivered':
|
||||
return ('Delivered', 'delivered')
|
||||
if ship_status == 'in_transit':
|
||||
return ('In Transit', 'shipping')
|
||||
|
||||
# WO phase — figure out total steps and the current step name.
|
||||
tot = 0
|
||||
current_step_name = None
|
||||
Job = so.env.get('fp.job')
|
||||
if Job is not None and so.name:
|
||||
jobs = Job.sudo().search([('origin', '=', so.name)])
|
||||
if jobs:
|
||||
steps = jobs.mapped('step_ids').sorted(
|
||||
lambda s: (s.job_id.id, s.sequence)
|
||||
)
|
||||
tot = len(steps)
|
||||
# Priority: in_progress → paused → next ready/pending.
|
||||
current = (
|
||||
steps.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
or steps.filtered(lambda s: s.state == 'paused')[:1]
|
||||
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
|
||||
)
|
||||
current_step_name = current.name if current else None
|
||||
|
||||
all_steps_done = tot > 0 and current_step_name is None
|
||||
|
||||
if all_steps_done:
|
||||
if ship_status == 'booked':
|
||||
return ('Ship Booked', 'shipping')
|
||||
return ('Ready to Ship', 'primary')
|
||||
if current_step_name:
|
||||
return (current_step_name, 'info')
|
||||
|
||||
# Receiving phase (no WO yet).
|
||||
recv = so.x_fc_receiving_status or 'not_received'
|
||||
if recv == 'received':
|
||||
return ('Ready to Start', 'primary')
|
||||
if recv == 'partial':
|
||||
return ('Parts Partial', 'warning')
|
||||
return ('Awaiting Parts', 'warning')
|
||||
|
||||
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
|
||||
def _compute_lead_time_display(self):
|
||||
"""Render the lead time as a human-readable string for reports.
|
||||
|
||||
Priority order:
|
||||
- Real min/max range set → "X-Y days" or "X days"
|
||||
- Range not set, rush_order on → "Rush"
|
||||
- Otherwise → "Standard"
|
||||
"""
|
||||
for so in self:
|
||||
mn = so.x_fc_lead_time_min_days or 0
|
||||
mx = so.x_fc_lead_time_max_days or 0
|
||||
if mn and mx and mn != mx:
|
||||
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
|
||||
elif mx or mn:
|
||||
so.x_fc_lead_time_display = '%d days' % (mx or mn)
|
||||
elif so.x_fc_rush_order:
|
||||
so.x_fc_lead_time_display = 'Rush'
|
||||
else:
|
||||
so.x_fc_lead_time_display = 'Standard'
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_wo_completion(self):
|
||||
@@ -493,9 +686,11 @@ class SaleOrder(models.Model):
|
||||
def _compute_deadline_countdown(self):
|
||||
from datetime import datetime
|
||||
now = fields.Datetime.now()
|
||||
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||
for rec in self:
|
||||
if not rec.commitment_date:
|
||||
rec.x_fc_deadline_countdown = False
|
||||
rec.x_fc_deadline_urgency = False
|
||||
continue
|
||||
target = rec.commitment_date
|
||||
if isinstance(target, datetime):
|
||||
@@ -506,12 +701,13 @@ class SaleOrder(models.Model):
|
||||
secs = int(delta.total_seconds())
|
||||
if secs == 0:
|
||||
rec.x_fc_deadline_countdown = 'due now'
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
continue
|
||||
past = secs < 0
|
||||
secs = abs(secs)
|
||||
days = secs // 86400
|
||||
hours = (secs % 86400) // 3600
|
||||
mins = (secs % 3600) // 60
|
||||
abs_secs = abs(secs)
|
||||
days = abs_secs // 86400
|
||||
hours = (abs_secs % 86400) // 3600
|
||||
mins = (abs_secs % 3600) // 60
|
||||
bits = []
|
||||
if days:
|
||||
bits.append('%dd' % days)
|
||||
@@ -523,6 +719,12 @@ class SaleOrder(models.Model):
|
||||
rec.x_fc_deadline_countdown = (
|
||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||
)
|
||||
if past:
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
elif secs <= TWO_DAYS:
|
||||
rec.x_fc_deadline_urgency = 'urgent'
|
||||
else:
|
||||
rec.x_fc_deadline_urgency = 'safe'
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_effective_part_deadline',
|
||||
|
||||
@@ -42,3 +42,5 @@ access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part
|
||||
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
|
||||
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,68 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Status pill on the SO list
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// One pill per row, one colour per phase, vibrant + saturated so phases
|
||||
// pop at a glance against both the light and dark Odoo bundles. Same
|
||||
// hue map for both modes — saturated 500-level Tailwind hues with white
|
||||
// text give consistent contrast against either page background.
|
||||
// =============================================================================
|
||||
|
||||
// ----- Vibrant tints (light + dark) -----
|
||||
$_fp-muted-bg : #6b7280; // slate
|
||||
$_fp-warning-bg : #f59e0b; // amber
|
||||
$_fp-primary-bg : #8b5cf6; // violet
|
||||
$_fp-info-bg : #3b82f6; // blue
|
||||
$_fp-shipping-bg : #06b6d4; // cyan
|
||||
$_fp-delivered-bg : #14b8a6; // teal
|
||||
$_fp-invoiced-bg : #84cc16; // lime
|
||||
$_fp-paid-bg : #16a34a; // green
|
||||
$_fp-danger-bg : #ef4444; // red
|
||||
|
||||
// Matching glow shadows — darker tone of the same hue for a subtle
|
||||
// drop-shadow that gives the pill a "lifted" feel without being noisy.
|
||||
$_fp-muted-glow : rgba(31, 41, 55, 0.35);
|
||||
$_fp-warning-glow : rgba(180, 83, 9, 0.45);
|
||||
$_fp-primary-glow : rgba(91, 33, 182, 0.45);
|
||||
$_fp-info-glow : rgba(29, 78, 216, 0.45);
|
||||
$_fp-shipping-glow : rgba(14, 116, 144, 0.45);
|
||||
$_fp-delivered-glow : rgba(15, 118, 110, 0.45);
|
||||
$_fp-invoiced-glow : rgba(101, 163, 13, 0.45);
|
||||
$_fp-paid-glow : rgba(21, 128, 61, 0.5);
|
||||
$_fp-danger-glow : rgba(185, 28, 28, 0.45);
|
||||
|
||||
// =============================================================================
|
||||
// Pill base
|
||||
// =============================================================================
|
||||
.fp-job-status {
|
||||
display: inline-block;
|
||||
padding: 0.4em 0.95em;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.015em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
min-width: 72px;
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-kind tints — same map applies to light + dark bundles. White text
|
||||
// gives consistent contrast against any saturated mid-tone hue.
|
||||
// =============================================================================
|
||||
.fp-kind-muted { background-color: $_fp-muted-bg; box-shadow: 0 1px 3px $_fp-muted-glow; }
|
||||
.fp-kind-warning { background-color: $_fp-warning-bg; box-shadow: 0 1px 3px $_fp-warning-glow; }
|
||||
.fp-kind-primary { background-color: $_fp-primary-bg; box-shadow: 0 1px 3px $_fp-primary-glow; }
|
||||
.fp-kind-info { background-color: $_fp-info-bg; box-shadow: 0 1px 3px $_fp-info-glow; }
|
||||
.fp-kind-shipping { background-color: $_fp-shipping-bg; box-shadow: 0 1px 3px $_fp-shipping-glow; }
|
||||
.fp-kind-delivered { background-color: $_fp-delivered-bg; box-shadow: 0 1px 3px $_fp-delivered-glow; }
|
||||
.fp-kind-invoiced { background-color: $_fp-invoiced-bg; box-shadow: 0 1px 3px $_fp-invoiced-glow; }
|
||||
.fp-kind-paid {
|
||||
background-color: $_fp-paid-bg;
|
||||
box-shadow: 0 1px 4px $_fp-paid-glow;
|
||||
font-weight: 700;
|
||||
}
|
||||
.fp-kind-danger { background-color: $_fp-danger-bg; box-shadow: 0 1px 3px $_fp-danger-glow; }
|
||||
@@ -0,0 +1,261 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Job Sorting:
|
||||
- Section model views (list/form) under Configuration → Sales.
|
||||
- Alternate SO list ("Sale Orders by Sorting") grouped by job sort
|
||||
with foldable sections and create-from-here support.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== Section management (Configuration) ===== -->
|
||||
<record id="view_fp_so_job_sort_list" model="ir.ui.view">
|
||||
<field name="name">fp.so.job.sort.list</field>
|
||||
<field name="model">fp.so.job.sort</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Job Sorting" editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
<field name="fold" widget="boolean_toggle"/>
|
||||
<field name="sale_order_count"/>
|
||||
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_so_job_sort_form" model="ir.ui.view">
|
||||
<field name="name">fp.so.job.sort.form</field>
|
||||
<field name="model">fp.so.job.sort</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Job Sorting">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_orders" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart">
|
||||
<field name="sale_order_count" widget="statinfo"
|
||||
string="Sale Orders"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Rush Orders"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
<field name="fold"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description"
|
||||
placeholder="What kinds of orders belong in this section?"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_so_job_sort" model="ir.actions.act_window">
|
||||
<field name="name">Job Sorting</field>
|
||||
<field name="res_model">fp.so.job.sort</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_so_job_sort"
|
||||
name="Job Sorting"
|
||||
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||
action="action_fp_so_job_sort"
|
||||
sequence="25"/>
|
||||
|
||||
<!-- ===== Kanban grouped by Job Sorting =====
|
||||
Groups SOs into foldable columns by x_fc_job_sort_id.
|
||||
Drag-drop between columns rewrites the bucket; quick-create on
|
||||
the column header creates a new fp.so.job.sort row. Wired into
|
||||
the existing Sale Orders action below so it shows up in the
|
||||
view-switcher next to the flat list. -->
|
||||
<record id="view_sale_order_kanban_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.kanban.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="x_fc_job_sort_id"
|
||||
group_create="true"
|
||||
group_edit="true"
|
||||
group_delete="true"
|
||||
quick_create="false"
|
||||
sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount_total"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="x_fc_part_numbers_summary"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_deadline_countdown"/>
|
||||
<field name="x_fc_deadline_urgency"/>
|
||||
<field name="x_fc_fp_job_status"/>
|
||||
<field name="state"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="o_kanban_card_content p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<strong><field name="name"/></strong>
|
||||
<span t-att-class="'badge ' + (
|
||||
record.x_fc_deadline_urgency.raw_value == 'overdue' and 'text-bg-danger' or
|
||||
record.x_fc_deadline_urgency.raw_value == 'urgent' and 'text-bg-warning' or
|
||||
record.x_fc_deadline_urgency.raw_value == 'safe' and 'text-bg-success' or
|
||||
'text-bg-light')"
|
||||
t-if="record.x_fc_deadline_countdown.raw_value">
|
||||
<field name="x_fc_deadline_countdown"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted small mb-1">
|
||||
<field name="partner_id"/>
|
||||
</div>
|
||||
<div class="small mb-1" t-if="record.x_fc_part_numbers_summary.raw_value">
|
||||
<i class="fa fa-cube me-1"/>
|
||||
<field name="x_fc_part_numbers_summary"/>
|
||||
</div>
|
||||
<div class="small mb-2" t-if="record.x_fc_customer_job_number.raw_value">
|
||||
<i class="fa fa-hashtag me-1"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<field name="x_fc_fp_job_status" widget="html"/>
|
||||
<strong>
|
||||
<field name="amount_total" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Sale Orders by Sorting (alternate SO list) ===== -->
|
||||
<!-- Duplicate of view_sale_order_list_fp but renamed and intended
|
||||
to be opened with group_by=x_fc_job_sort_id by default so the
|
||||
user sees foldable sections per Job Sorting bucket. -->
|
||||
<record id="view_sale_order_list_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.list.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="priority">99</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sale Orders by Sorting" create="0"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-muted="state == 'cancel'"
|
||||
decoration-danger="x_fc_is_late_forecast">
|
||||
<header>
|
||||
<button name="%(action_fp_direct_order_wizard)d"
|
||||
type="action"
|
||||
string="New Order"
|
||||
class="btn-primary"
|
||||
display="always"/>
|
||||
</header>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="show"/>
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_job_sort_id" optional="show"
|
||||
options="{'no_create_edit': False, 'no_open': True}"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline"
|
||||
optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion"
|
||||
optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"
|
||||
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
<field name="x_fc_part_numbers_summary" string="Part"
|
||||
optional="show"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_invoiced_amount" sum="Invoiced"
|
||||
optional="hide"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="x_fc_fp_job_status" widget="html"
|
||||
string="Job Status" optional="show" readonly="1"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
optional="hide"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-info="x_fc_receiving_status == 'partial'"
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search view for the alternate list: surface "Group by Job
|
||||
Sorting" as a search-default filter. -->
|
||||
<record id="view_sale_order_search_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.search.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Sale Orders by Sorting">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="x_fc_part_numbers_summary" string="Part"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="x_fc_job_sort_id"/>
|
||||
<filter name="late_forecast" string="Late Forecast"
|
||||
domain="[('x_fc_is_late_forecast','=',True)]"/>
|
||||
<filter name="cancelled" string="Cancelled"
|
||||
domain="[('state','=','cancel')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_by_job_sort"
|
||||
string="Job Sorting"
|
||||
context="{'group_by': 'x_fc_job_sort_id'}"/>
|
||||
<filter name="group_by_customer"
|
||||
string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
<filter name="group_by_state"
|
||||
string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Append the kanban view to the existing Sale Orders action so
|
||||
users can switch from the flat list to the grouped-by-sorting
|
||||
kanban (foldable columns, drag-drop bucket reassignment) via
|
||||
the view-switcher icon in the top-right of the SO list. -->
|
||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_sale_order_kanban_fp_by_sorting')})]"/>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_sale_orders_by_sorting" model="ir.actions.act_window">
|
||||
<field name="name">Sale Orders (by Sorting)</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="view_sale_order_list_fp_by_sorting"/>
|
||||
<field name="search_view_id" ref="view_sale_order_search_fp_by_sorting"/>
|
||||
<field name="domain">[('state', 'not in', ('draft', 'sent'))]</field>
|
||||
<field name="context">{'search_default_group_by_job_sort': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_sale_orders_by_sorting"
|
||||
name="Sale Orders (by Sorting)"
|
||||
parent="fusion_plating_configurator.menu_fp_sales"
|
||||
action="action_fp_sale_orders_by_sorting"
|
||||
sequence="12"/>
|
||||
|
||||
</odoo>
|
||||
@@ -123,6 +123,15 @@
|
||||
<field name="commitment_date" string="Delivery Date"
|
||||
readonly="state in ('cancel',)"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Job Sorting sits right under Payment Terms — a free-form
|
||||
bucket that groups the SO in the "Sale Orders by Sorting"
|
||||
list. Quick-create from the dropdown. -->
|
||||
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="after">
|
||||
<field name="x_fc_job_sort_id"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
<!-- Multi-part summary: read-only list of every order line
|
||||
@@ -201,6 +210,16 @@
|
||||
</div>
|
||||
<field name="x_fc_is_blanket_order"/>
|
||||
<field name="x_fc_block_partial_shipments"/>
|
||||
<!-- Lead Time range. Both 0 = "Standard" on
|
||||
the PDF; otherwise renders "X-Y days"
|
||||
(or "X days" if min==max or one is 0). -->
|
||||
<label for="x_fc_lead_time_min_days" string="Lead Time (days)"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_lead_time_min_days" class="oe_inline" style="width: 4em;"/>
|
||||
<span> to </span>
|
||||
<field name="x_fc_lead_time_max_days" class="oe_inline" style="width: 4em;"/>
|
||||
</div>
|
||||
<field name="x_fc_lead_time_display" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@@ -358,19 +377,29 @@
|
||||
class="btn-primary"
|
||||
display="always"/>
|
||||
</header>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="show"/>
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"/>
|
||||
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"
|
||||
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
<field name="x_fc_part_catalog_id" optional="hide"/>
|
||||
<field name="amount_total" sum="Total"/>
|
||||
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
|
||||
and shows a compact summary (e.g. "M1234, M5678
|
||||
(+3 more)"). The header x_fc_part_catalog_id field
|
||||
is rarely populated in the configurator flow; the
|
||||
line carries the authoritative part link. -->
|
||||
<field name="x_fc_part_numbers_summary" string="Part"
|
||||
optional="show"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
@@ -380,12 +409,21 @@
|
||||
<field name="x_fc_margin_percent" optional="hide"
|
||||
widget="percentage"/>
|
||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||
<!-- Single Job Status pill. Renders as HTML with a
|
||||
per-kind class (.fp-kind-*) so every phase carries
|
||||
its own distinct tint — see
|
||||
static/src/scss/fp_job_status_pill.scss. -->
|
||||
<field name="x_fc_fp_job_status" widget="html"
|
||||
string="Job Status" optional="show"
|
||||
readonly="1"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
optional="hide"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-info="x_fc_receiving_status == 'partial'"
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@@ -455,8 +493,8 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Quotations" decoration-muted="state == 'cancel'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_part_numbers_summary" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="hide"/>
|
||||
<field name="x_fc_customer_job_number" optional="hide"/>
|
||||
@@ -464,15 +502,16 @@
|
||||
<field name="validity_date" string="Expires" optional="show"/>
|
||||
<field name="x_fc_follow_up_date" optional="show"/>
|
||||
<field name="x_fc_follow_up_user_id" optional="show"/>
|
||||
<field name="amount_total" sum="Total"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_is_signed" widget="boolean_toggle"
|
||||
string="Signed" optional="show"/>
|
||||
<field name="x_fc_email_status" widget="badge"
|
||||
optional="show"
|
||||
decoration-info="x_fc_email_status == 'sent'"
|
||||
decoration-warning="x_fc_email_status == 'opened'"
|
||||
decoration-success="x_fc_email_status == 'won'"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@@ -572,7 +611,10 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
|
||||
<!-- ===== Window Action — Confirmed Sale Orders =====
|
||||
The kanban view_mode + kanban view_id are appended in
|
||||
fp_so_job_sort_views.xml after the kanban view is defined, so
|
||||
we don't hit a missing-ref at module load. -->
|
||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||
<field name="name">Sale Orders</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
|
||||
@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
|
||||
help="Customer's internal job number for cross-referencing. "
|
||||
"Appears on work orders and invoices.",
|
||||
)
|
||||
job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
help='Free-form bucket that groups the new SO in the '
|
||||
'"Sale Orders by Sorting" list. Type a new label in the '
|
||||
'dropdown to create a section on the fly.',
|
||||
)
|
||||
|
||||
# ---- Scheduling ----
|
||||
planned_start_date = fields.Date(
|
||||
@@ -86,6 +93,11 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
internal_deadline = fields.Date(string='Internal Deadline')
|
||||
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
|
||||
# Lead Time — promised production window. Mirrors directly to
|
||||
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
|
||||
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
|
||||
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
|
||||
lead_time_max_days = fields.Integer(string='Lead Time Max (days)')
|
||||
|
||||
# ---- Order flags (Phase B) ----
|
||||
is_blanket_order = fields.Boolean(
|
||||
@@ -528,8 +540,11 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_po_pending': self.po_pending,
|
||||
'x_fc_po_expected_date': self.po_expected_date or False,
|
||||
'x_fc_customer_job_number': self.customer_job_number or False,
|
||||
'x_fc_job_sort_id': self.job_sort_id.id or False,
|
||||
'x_fc_planned_start_date': self.planned_start_date,
|
||||
'x_fc_internal_deadline': self.internal_deadline,
|
||||
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
|
||||
'x_fc_lead_time_max_days': self.lead_time_max_days or 0,
|
||||
# commitment_date is a Datetime; customer_deadline is a Date.
|
||||
# Assigning a bare Date stores midnight UTC, which renders as
|
||||
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
options="{'no_create_edit': True}"
|
||||
invisible="not partner_id"/>
|
||||
<field name="customer_job_number"/>
|
||||
<field name="job_sort_id"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</group>
|
||||
<group string="Purchase Order">
|
||||
<field name="po_number"
|
||||
@@ -102,6 +105,14 @@
|
||||
still `customer_deadline` (wizard) →
|
||||
`commitment_date` (SO). -->
|
||||
<field name="customer_deadline" string="Delivery Date"/>
|
||||
<!-- Lead time range (min/max business days).
|
||||
Both 0 = "Standard" on the SO confirm PDF. -->
|
||||
<label for="lead_time_min_days" string="Lead Time (days)"/>
|
||||
<div class="o_row">
|
||||
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
|
||||
<span> to </span>
|
||||
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
|
||||
</div>
|
||||
<field name="is_blanket_order"/>
|
||||
<field name="block_partial_shipments"/>
|
||||
</group>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.16.9',
|
||||
'version': '19.0.10.19.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -57,11 +57,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
'views/fp_workflow_state_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_job_step_quick_look_views.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
'views/fp_job_quality_buttons.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_views.xml',
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/fp_job_consumption_views.xml',
|
||||
'views/fp_step_priority_views.xml',
|
||||
|
||||
@@ -22,6 +22,7 @@ from . import fp_certificate
|
||||
from . import fp_thickness_reading
|
||||
from . import fp_delivery
|
||||
from . import fp_racking_inspection
|
||||
from . import fp_receiving
|
||||
|
||||
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
||||
from . import fp_notification_trigger
|
||||
|
||||
@@ -137,10 +137,13 @@ class AccountMove(models.Model):
|
||||
if not job or not job.portal_job_id:
|
||||
return
|
||||
portal = job.portal_job_id
|
||||
if 'state' in portal._fields:
|
||||
portal.state = 'complete'
|
||||
if 'invoice_ref' in portal._fields:
|
||||
portal.invoice_ref = self.name
|
||||
# Recompute state via the central helper — it'll only land on
|
||||
# 'complete' if the WO is actually done AND the shipment is
|
||||
# delivered. Posting an invoice early no longer skips the floor.
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal._fp_recompute_portal_state()
|
||||
_logger.info(
|
||||
'Invoice %s linked to fp.job %s portal %s',
|
||||
self.name, job.name, portal.name,
|
||||
|
||||
@@ -97,6 +97,30 @@ class FpJob(models.Model):
|
||||
'idempotency. Cleared post-cutover.',
|
||||
)
|
||||
|
||||
# Display formatter — "WO # 00001" used everywhere on tablet/dashboard.
|
||||
# The underlying `name` field stays untouched (WH/JOB/00001) so reports,
|
||||
# emails, and back-office forms continue using their canonical name.
|
||||
# System-wide sequence rename is a separate decision (see spec
|
||||
# 2026-05-22-shopfloor-tablet-redesign-design §6.5).
|
||||
display_wo_name = fields.Char(
|
||||
compute='_compute_display_wo_name',
|
||||
string='WO #',
|
||||
help='Tablet/dashboard formatter — "WO # 00001" derived from name. '
|
||||
'Underlying name field is unchanged.',
|
||||
)
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_display_wo_name(self):
|
||||
for job in self:
|
||||
raw = (job.name or '').strip()
|
||||
if not raw:
|
||||
job.display_wo_name = ''
|
||||
continue
|
||||
# Take the last "/"-separated segment as the number portion.
|
||||
# WH/JOB/00001 → 00001 ; WH/JOB/2026/00042 → 00042 ; 00123 → 00123
|
||||
tail = raw.rsplit('/', 1)[-1]
|
||||
job.display_wo_name = f'WO # {tail}'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub 14 — Configurable workflow state (status bar milestone)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -552,6 +576,22 @@ class FpJob(models.Model):
|
||||
'context': {'default_job_id': self.id},
|
||||
}
|
||||
|
||||
def action_open_workspace(self):
|
||||
"""Open the JobWorkspace OWL client action focused on this job.
|
||||
|
||||
Spec: 2026-05-22-shopfloor-tablet-redesign — Phase 1 deliverable.
|
||||
Used as the smart-button entry point before the Landing kanban
|
||||
(Phase 3) is shipped, and stays as a back-office shortcut after.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_job_workspace',
|
||||
'name': self.display_wo_name or self.name,
|
||||
'params': {'job_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_finish_current_step(self):
|
||||
"""Steelhead-style header button: finish whatever's currently
|
||||
in_progress and auto-start the next pending/ready step. If
|
||||
@@ -745,16 +785,10 @@ class FpJob(models.Model):
|
||||
'name': self.portal_job_id.name,
|
||||
}
|
||||
|
||||
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
|
||||
# the customer doesn't see internal states. Anything not in this map
|
||||
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
|
||||
_FP_JOB_STATE_TO_PORTAL_STATE = {
|
||||
'confirmed': 'received',
|
||||
'in_progress': 'in_progress',
|
||||
'done': 'ready_to_ship',
|
||||
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
|
||||
# what to surface to the customer.
|
||||
}
|
||||
# Sub-portal state sync — see fusion_plating_portal/.../fp_portal_job.py
|
||||
# `_fp_recompute_portal_state` for the rules. The mapping table that
|
||||
# used to live here was replaced by the helper so shipment / invoice
|
||||
# signals can't drift away from the WO state any more.
|
||||
|
||||
def write(self, vals):
|
||||
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
||||
@@ -783,13 +817,13 @@ class FpJob(models.Model):
|
||||
if job.state != new_state:
|
||||
state_changed_ids.add(job.id)
|
||||
result = super().write(vals)
|
||||
# Mirror state to portal_job for records that actually changed.
|
||||
# Mirror state to portal_job via the central recompute helper, so
|
||||
# the portal state always derives from the WO + shipment + invoice
|
||||
# together rather than the most-recent event flag.
|
||||
if state_changed_ids:
|
||||
target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
|
||||
if target:
|
||||
for job in self.filtered(lambda j: j.id in state_changed_ids):
|
||||
if job.portal_job_id and job.portal_job_id.state != target:
|
||||
job.portal_job_id.sudo().write({'state': target})
|
||||
for job in self.filtered(lambda j: j.id in state_changed_ids):
|
||||
if job.portal_job_id:
|
||||
job.portal_job_id._fp_recompute_portal_state()
|
||||
if not scrap_deltas:
|
||||
return result
|
||||
Hold = (self.env['fusion.plating.quality.hold']
|
||||
|
||||
@@ -85,6 +85,72 @@ class FpJobStep(models.Model):
|
||||
)
|
||||
step.can_start = not bool(blocking)
|
||||
|
||||
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
||||
# Returns kind of blocker + human reason + optional (model, id) jump
|
||||
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
||||
# with can_start as a single source of truth.
|
||||
blocker_kind = fields.Selection(
|
||||
[
|
||||
('none', 'Not blocked'),
|
||||
('predecessor', 'Waiting on predecessor'),
|
||||
('contract_review', 'Contract review pending'),
|
||||
('parts_not_received', 'Parts not received'),
|
||||
('racking_required', 'Racking inspection required'),
|
||||
('manager_input', 'Manager input required'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
compute='_compute_blocker',
|
||||
string='Blocker Kind',
|
||||
)
|
||||
blocker_reason = fields.Char(
|
||||
compute='_compute_blocker',
|
||||
string='Blocker Reason',
|
||||
help='Human-readable explanation surfaced in the GateViz block.',
|
||||
)
|
||||
blocker_jump_target_model = fields.Char(compute='_compute_blocker')
|
||||
blocker_jump_target_id = fields.Integer(compute='_compute_blocker')
|
||||
|
||||
@api.depends(
|
||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
||||
)
|
||||
def _compute_blocker(self):
|
||||
for step in self:
|
||||
# Terminal/in-progress states are never "blocked"
|
||||
if step.state in ('done', 'skipped', 'cancelled', 'in_progress'):
|
||||
step.blocker_kind = 'none'
|
||||
step.blocker_reason = ''
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
continue
|
||||
|
||||
# Predecessor gate — same policy as _compute_can_start
|
||||
if step._fp_should_block_predecessors():
|
||||
earlier_open = step.job_id.step_ids.filtered(lambda x: (
|
||||
x.id != step.id
|
||||
and x.sequence < step.sequence
|
||||
and x.state not in ('done', 'skipped', 'cancelled')
|
||||
))
|
||||
if earlier_open:
|
||||
first_blocker = earlier_open.sorted('sequence')[0]
|
||||
step.blocker_kind = 'predecessor'
|
||||
seq_disp = (first_blocker.sequence or 0) // 10
|
||||
step.blocker_reason = (
|
||||
f'Waiting on Step {seq_disp}: {first_blocker.name}'
|
||||
)
|
||||
step.blocker_jump_target_model = 'fp.job.step'
|
||||
step.blocker_jump_target_id = first_blocker.id
|
||||
continue
|
||||
|
||||
# Future: extend with explicit checks for contract_review /
|
||||
# parts_not_received / racking_required / manager_input as
|
||||
# those gate models mature. For now, default to 'none'.
|
||||
step.blocker_kind = 'none'
|
||||
step.blocker_reason = ''
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
|
||||
# NOTE: the actual button_start override lives further down (~line
|
||||
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
||||
|
||||
67
fusion_plating/fusion_plating_jobs/models/fp_receiving.py
Normal file
67
fusion_plating/fusion_plating_jobs/models/fp_receiving.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Adds the Work Order smart button + header action to fp.receiving so
|
||||
# the receiving form mirrors the SO's WO entry point. Button appears
|
||||
# once the receiving is closed and stays until every linked fp.job
|
||||
# reaches state='done'.
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
x_fc_fp_job_count = fields.Integer(
|
||||
string='Work Orders',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_show_work_order_btn = fields.Boolean(
|
||||
string='Show Work Order Button',
|
||||
compute='_compute_show_work_order_btn',
|
||||
help='True once this receiving is closed and at least one linked '
|
||||
'work order is still open (state != done). Hidden again '
|
||||
'when every job is done.',
|
||||
)
|
||||
|
||||
def _compute_fp_job_count(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for rec in self:
|
||||
if rec.sale_order_id:
|
||||
rec.x_fc_fp_job_count = Job.search_count(
|
||||
[('sale_order_id', '=', rec.sale_order_id.id)]
|
||||
)
|
||||
else:
|
||||
rec.x_fc_fp_job_count = 0
|
||||
|
||||
def _compute_show_work_order_btn(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for rec in self:
|
||||
if rec.state != 'closed' or not rec.sale_order_id:
|
||||
rec.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
|
||||
rec.x_fc_show_work_order_btn = bool(jobs) and any(
|
||||
j.state != 'done' for j in jobs
|
||||
)
|
||||
|
||||
def action_view_fp_jobs(self):
|
||||
"""Open the work order(s) linked to this receiving's sale order."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return False
|
||||
jobs = self.env['fp.job'].search([
|
||||
('sale_order_id', '=', self.sale_order_id.id),
|
||||
])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Work Orders'),
|
||||
'res_model': 'fp.job',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.sale_order_id.id)],
|
||||
'context': {'default_sale_order_id': self.sale_order_id.id},
|
||||
}
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
@@ -24,6 +24,13 @@ class SaleOrder(models.Model):
|
||||
string='Work Orders',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_show_work_order_btn = fields.Boolean(
|
||||
string='Show Work Order Header Button',
|
||||
compute='_compute_show_work_order_btn',
|
||||
help='True once any receiving record on this SO has closed and '
|
||||
'at least one work order is still open (state != done). '
|
||||
'Hidden again when every WO is done.',
|
||||
)
|
||||
x_fc_fp_certificate_count = fields.Integer(
|
||||
string='Certificates',
|
||||
compute='_compute_fp_certificate_count',
|
||||
@@ -114,6 +121,25 @@ class SaleOrder(models.Model):
|
||||
[('sale_order_id', '=', so.id)]
|
||||
)
|
||||
|
||||
def _compute_show_work_order_btn(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
for so in self:
|
||||
if Recv is None:
|
||||
so.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
has_closed_recv = bool(Recv.sudo().search_count([
|
||||
('sale_order_id', '=', so.id),
|
||||
('state', '=', 'closed'),
|
||||
]))
|
||||
if not has_closed_recv:
|
||||
so.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
jobs = Job.search([('sale_order_id', '=', so.id)])
|
||||
so.x_fc_show_work_order_btn = bool(jobs) and any(
|
||||
j.state != 'done' for j in jobs
|
||||
)
|
||||
|
||||
def _compute_fp_certificate_count(self):
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
for so in self:
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
from . import test_qty_received_propagation
|
||||
from . import test_display_wo_name
|
||||
from . import test_blocker_compute
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestBlockerCompute(TransactionCase):
|
||||
"""fp.job.step.blocker_kind / blocker_reason / blocker_jump_target_*
|
||||
— Gate visualizer source of truth for the OWL GateViz component.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Test Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/T1',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def _make_step(self, name, sequence, state='ready'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': name,
|
||||
'sequence': sequence,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
def test_terminal_step_has_no_blocker(self):
|
||||
step = self._make_step('Done step', 10, state='done')
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
self.assertEqual(step.blocker_reason, '')
|
||||
|
||||
def test_in_progress_step_has_no_blocker(self):
|
||||
step = self._make_step('Running step', 10, state='in_progress')
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
|
||||
def test_solo_ready_step_not_blocked(self):
|
||||
step = self._make_step('Solo', 10, state='ready')
|
||||
# No predecessor → blocker_kind = 'none'
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
self.assertEqual(step.blocker_reason, '')
|
||||
|
||||
def test_predecessor_open_blocks(self):
|
||||
s1 = self._make_step('Earlier', 10, state='in_progress')
|
||||
s2 = self._make_step('Later', 20, state='ready')
|
||||
# If recipe enforces sequential OR step requires predecessor,
|
||||
# blocker_kind should be 'predecessor'. Default depends on job
|
||||
# config; if neither flag triggers we'd see 'none' instead.
|
||||
s2.invalidate_recordset([
|
||||
'blocker_kind', 'blocker_reason',
|
||||
'blocker_jump_target_model', 'blocker_jump_target_id',
|
||||
])
|
||||
if s2._fp_should_block_predecessors():
|
||||
self.assertEqual(s2.blocker_kind, 'predecessor')
|
||||
self.assertIn(s1.name, s2.blocker_reason or '')
|
||||
self.assertEqual(s2.blocker_jump_target_model, 'fp.job.step')
|
||||
self.assertEqual(s2.blocker_jump_target_id, s1.id)
|
||||
else:
|
||||
self.assertEqual(s2.blocker_kind, 'none')
|
||||
|
||||
def test_explicit_requires_predecessor_blocks(self):
|
||||
s1 = self._make_step('Earlier', 10, state='ready')
|
||||
s2 = self._make_step('Later', 20, state='ready')
|
||||
s2.requires_predecessor_done = True
|
||||
s2.invalidate_recordset(['blocker_kind', 'blocker_reason'])
|
||||
self.assertEqual(s2.blocker_kind, 'predecessor')
|
||||
self.assertEqual(s2.blocker_jump_target_id, s1.id)
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestDisplayWoName(TransactionCase):
|
||||
"""fp.job.display_wo_name — Tablet/dashboard formatter."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Test Prod'})
|
||||
|
||||
def _make_job(self, name):
|
||||
return self.env['fp.job'].create({
|
||||
'name': name,
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_wh_job_prefix_formatted(self):
|
||||
job = self._make_job('WH/JOB/00001')
|
||||
self.assertEqual(job.display_wo_name, 'WO # 00001')
|
||||
|
||||
def test_wh_job_with_year(self):
|
||||
job = self._make_job('WH/JOB/2026/00042')
|
||||
self.assertEqual(job.display_wo_name, 'WO # 00042')
|
||||
|
||||
def test_plain_numeric(self):
|
||||
job = self._make_job('00123')
|
||||
self.assertEqual(job.display_wo_name, 'WO # 00123')
|
||||
|
||||
def test_falsy_name(self):
|
||||
# New record before save → name is False; computed returns empty
|
||||
job = self.env['fp.job'].new({'name': False})
|
||||
self.assertEqual(job.display_wo_name, '')
|
||||
@@ -20,6 +20,15 @@
|
||||
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<!-- Phase 1 — Tablet redesign. Opens the JobWorkspace OWL
|
||||
client action focused on this WO. Primary entry point
|
||||
for techs before the Landing kanban (Phase 3) ships;
|
||||
stays as a back-office shortcut after. -->
|
||||
<button name="action_open_workspace" type="object"
|
||||
string="Open Workspace"
|
||||
class="btn-primary"
|
||||
icon="fa-tablet"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_open_process_tree" type="object"
|
||||
string="Process Tree"
|
||||
class="btn-secondary"
|
||||
@@ -63,15 +72,10 @@
|
||||
<field name="all_steps_terminal" invisible="1"/>
|
||||
<field name="next_milestone_action" invisible="1"/>
|
||||
<button name="action_print_sticker" type="object"
|
||||
string="Print Sticker"
|
||||
class="btn-secondary"
|
||||
icon="fa-tag"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_print_wo_detail" type="object"
|
||||
string="Print WO Detail"
|
||||
class="btn-secondary"
|
||||
icon="fa-file-text-o"
|
||||
invisible="state in ('draft', 'cancelled')"/>
|
||||
icon="fa-qrcode"
|
||||
invisible="state == 'draft'"
|
||||
help="Print Sticker"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Adds the Work Order smart button + header button to fp.receiving so
|
||||
the receiving form mirrors the SO's WO entry point. Header button
|
||||
appears once state == 'closed' and at least one linked fp.job is
|
||||
still open. Smart button is always visible when WOs exist.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_receiving_form_fp_jobs" model="ir.ui.view">
|
||||
<field name="name">fp.receiving.form.fp.jobs</field>
|
||||
<field name="model">fp.receiving</field>
|
||||
<field name="inherit_id" ref="fusion_plating_receiving.view_fp_receiving_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Work Order header button — only after receiving is
|
||||
closed and while at least one job is still open. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||
<button name="action_view_fp_jobs"
|
||||
string="Work Order" type="object"
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order smart button on the button_box (mirrors the
|
||||
one on the SO form). Always visible when count > 0. -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_fp_jobs" type="object"
|
||||
class="oe_stat_button" icon="fa-cogs"
|
||||
invisible="x_fc_fp_job_count == 0">
|
||||
<field name="x_fc_fp_job_count" widget="statinfo" string="WO"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -33,6 +33,19 @@
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order header action — appears once any linked
|
||||
receiving is closed AND at least one WO is still open.
|
||||
Reuses the existing action_view_fp_jobs smart-button
|
||||
target so single-job SOs land on the form directly. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||
<button name="action_view_fp_jobs"
|
||||
string="Work Order" type="object"
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this order. Hidden automatically once every linked WO is marked Done."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
||||
line under the SO name (the big SO-30000 heading). Only
|
||||
renders once the SO has been confirmed (quote_ref is set
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.4.3.0',
|
||||
'version': '19.0.4.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||
'CoC downloads, invoice access.',
|
||||
|
||||
@@ -263,6 +263,83 @@ class FpPortalJob(models.Model):
|
||||
walk(mo.x_fc_recipe_id, 0)
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# State recompute — single source of truth derived from upstream models
|
||||
# ==========================================================================
|
||||
# The portal state should ALWAYS reflect the real shop-floor state of the
|
||||
# linked fp.job(s), the outbound shipment(s), and the customer invoice.
|
||||
# Earlier paths wrote state directly from each event hook (tracking number
|
||||
# arrived → 'shipped'; invoice posted → 'complete') which drifted out of
|
||||
# sync the moment any of those events fired before the job was actually
|
||||
# done — e.g. a FedEx label booked early would promote portal state to
|
||||
# 'shipped' even though the WO was still in 'confirmed'. The helper below
|
||||
# is the new single source of truth; the hooks now delegate to it.
|
||||
def _fp_recompute_portal_state(self):
|
||||
"""Derive portal state from fp.job + shipment + invoice and write
|
||||
it back if it differs. Safe to call from any sync hook; only
|
||||
writes when the target state actually changes."""
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is None:
|
||||
return
|
||||
for portal in self:
|
||||
jobs = Job.sudo().search([('portal_job_id', '=', portal.id)])
|
||||
if not jobs:
|
||||
# No linked job — leave manual edits alone.
|
||||
continue
|
||||
|
||||
all_done = all(j.state == 'done' for j in jobs)
|
||||
any_in_progress = any(
|
||||
j.state in ('in_progress', 'done') for j in jobs
|
||||
)
|
||||
|
||||
# Walk SO → fp.receiving → fusion.shipment for shipment status.
|
||||
ship_delivered = False
|
||||
ship_in_transit = False
|
||||
for j in jobs:
|
||||
so = j.sale_order_id
|
||||
if not so or 'x_fc_receiving_ids' not in so._fields:
|
||||
continue
|
||||
for recv in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
recv.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in recv._fields
|
||||
else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
if ship.status == 'delivered':
|
||||
ship_delivered = True
|
||||
elif ship.status == 'shipped':
|
||||
ship_in_transit = True
|
||||
|
||||
# Invoice signal — any posted customer invoice on the SO.
|
||||
invoiced = False
|
||||
for j in jobs:
|
||||
so = j.sale_order_id
|
||||
if not so:
|
||||
continue
|
||||
if any(
|
||||
m.state == 'posted' and m.move_type in ('out_invoice', 'out_refund')
|
||||
for m in so.invoice_ids
|
||||
):
|
||||
invoiced = True
|
||||
break
|
||||
|
||||
# Resolve target state.
|
||||
if all_done and invoiced and ship_delivered:
|
||||
target = 'complete'
|
||||
elif all_done and (ship_delivered or ship_in_transit):
|
||||
target = 'shipped'
|
||||
elif all_done:
|
||||
target = 'ready_to_ship'
|
||||
elif any_in_progress:
|
||||
target = 'in_progress'
|
||||
else:
|
||||
target = 'received'
|
||||
|
||||
if portal.state != target:
|
||||
portal.sudo().write({'state': target})
|
||||
|
||||
# ==========================================================================
|
||||
# Per-stage timestamp snapshots
|
||||
# ==========================================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.25.0',
|
||||
'version': '19.0.3.27.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
|
||||
@@ -160,14 +160,14 @@ class FusionShipment(models.Model):
|
||||
vals['packing_list_attachment_id'] = (
|
||||
delivery.packing_list_attachment_id.id
|
||||
)
|
||||
# Once a tracking number exists, the parts have been picked
|
||||
# by the carrier (or are about to be) — advance the portal
|
||||
# state to 'shipped' so the customer sees their order is
|
||||
# on its way. The 'delivered' status flips when FedEx
|
||||
# tracking reports the delivery.
|
||||
if self.tracking_number and portal.state in (
|
||||
'received', 'in_progress', 'ready_to_ship',
|
||||
):
|
||||
vals['state'] = 'shipped'
|
||||
if vals:
|
||||
portal.sudo().write(vals)
|
||||
# State is now derived centrally — see
|
||||
# fusion.plating.portal.job._fp_recompute_portal_state. It
|
||||
# only promotes to 'shipped' when every linked WO is done
|
||||
# AND the shipment.status is 'shipped' or 'delivered'. A
|
||||
# FedEx label booked early (tracking number without the
|
||||
# carrier actually picking up) no longer leapfrogs the
|
||||
# shop floor.
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal.sudo()._fp_recompute_portal_state()
|
||||
|
||||
@@ -15,12 +15,29 @@ class SaleOrder(models.Model):
|
||||
x_fc_receiving_count = fields.Integer(
|
||||
string='Receiving Count', compute='_compute_receiving_count',
|
||||
)
|
||||
x_fc_show_receive_parts_btn = fields.Boolean(
|
||||
string='Show Receive Parts Button',
|
||||
compute='_compute_show_receive_parts_btn',
|
||||
help='True once the SO is confirmed and there is still at least '
|
||||
'one receiving record that is not yet closed. Hidden again '
|
||||
'when every receiving record has been closed.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_receiving_ids')
|
||||
def _compute_receiving_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
||||
|
||||
@api.depends('state', 'x_fc_receiving_ids.state')
|
||||
def _compute_show_receive_parts_btn(self):
|
||||
for rec in self:
|
||||
if rec.state not in ('sale', 'done'):
|
||||
rec.x_fc_show_receive_parts_btn = False
|
||||
continue
|
||||
rec.x_fc_show_receive_parts_btn = any(
|
||||
r.state != 'closed' for r in rec.x_fc_receiving_ids
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create receiving record on SO confirmation.
|
||||
|
||||
|
||||
@@ -76,3 +76,25 @@ $fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Receive Parts header button — dark yellow / goldenrod
|
||||
// Applied to the SO-form header action that opens the receiving record(s).
|
||||
// Uses !important to defeat the .o_form_statusbar .btn cascades in both
|
||||
// the light and dark Odoo bundles.
|
||||
// =============================================================================
|
||||
.o_fp_receive_parts_btn,
|
||||
.o_form_statusbar .o_statusbar_buttons .o_fp_receive_parts_btn,
|
||||
button.o_fp_receive_parts_btn {
|
||||
background-color: #b8860b !important;
|
||||
border-color: #9e7700 !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: #9e7700 !important;
|
||||
border-color: #7a5b00 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.fa { color: #ffffff !important; }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
<field name="x_fc_receiving_count" widget="statinfo" string="Receiving"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Receive Parts header action — appears after SO confirmation
|
||||
while at least one receiving record is still open, and
|
||||
disappears once every receiving record is closed. Reuses
|
||||
the existing action_view_receiving method so a single
|
||||
receiving opens directly while multiples land on the list. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_receive_parts_btn" invisible="1"/>
|
||||
<button name="action_view_receiving"
|
||||
string="Receive Parts" type="object"
|
||||
class="btn o_fp_receive_parts_btn"
|
||||
icon="fa-archive"
|
||||
invisible="not x_fc_show_receive_parts_btn"
|
||||
help="Open the receiving record(s) for this order. Hidden automatically once all receiving records are closed."/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.26.14',
|
||||
'version': '19.0.11.26.30',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -90,7 +90,12 @@
|
||||
<t t-set="_desc"
|
||||
t-value="line.fp_customer_description() if _has_helper else line.name"/>
|
||||
<span t-esc="_desc" style="white-space: pre-line;"/>
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||
<!-- Serial line is suppressed when the calling template sets
|
||||
`fp_no_serial_in_desc=True` in `line.env.context`. SO
|
||||
portrait does this because it shows the serial in its own
|
||||
column in the part-number cell — otherwise it'd be
|
||||
duplicated. Invoice / packing slip still display it here. -->
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id and not line.env.context.get('fp_no_serial_in_desc')">
|
||||
<br/>
|
||||
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
||||
</t>
|
||||
|
||||
@@ -73,6 +73,26 @@
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- Compact A4 Landscape for customer-facing landscape reports. -->
|
||||
<!-- Same shape as `paperformat_fp_a4_portrait` (margin_top=8, -->
|
||||
<!-- header_spacing=0) just rotated. Bind alongside the portrait -->
|
||||
<!-- compact for any report that has a landscape variant. -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="paperformat_fp_a4_landscape_compact" model="report.paperformat">
|
||||
<field name="name">Fusion Plating A4 Landscape (Compact)</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="format">A4</field>
|
||||
<field name="orientation">Landscape</field>
|
||||
<field name="margin_top">8</field>
|
||||
<field name="margin_bottom">15</field>
|
||||
<field name="margin_left">10</field>
|
||||
<field name="margin_right">10</field>
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">0</field>
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 1. Certificate of Conformance (Portal Job) — Landscape -->
|
||||
<!-- ============================================================= -->
|
||||
@@ -394,9 +414,10 @@
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.sale_id.name.rsplit('-', 1)[-1] if object.sale_id else object.name)</field>
|
||||
<field name="binding_model_id" ref="stock.model_stock_picking"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_packing_slip_landscape" model="ir.actions.report">
|
||||
@@ -405,7 +426,7 @@
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_landscape</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_landscape</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.sale_id.name.rsplit('-', 1)[-1] if object.sale_id else object.name)</field>
|
||||
<field name="binding_model_id" ref="stock.model_stock_picking"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
@@ -450,6 +471,11 @@
|
||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="is_invoice_report" eval="True"/>
|
||||
<!-- Same compact paperformat as the SO confirmation so the
|
||||
inline custom header sits at the top of the page (not 40mm
|
||||
down under Odoo's default margin). See CLAUDE.md
|
||||
"wkhtmltopdf header overlap — the CoC pattern". -->
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
|
||||
@@ -461,7 +487,10 @@
|
||||
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
<!-- Compact landscape paperformat (same shape as the portrait
|
||||
compact, just rotated) so the inline header lands at the
|
||||
top with no auto-margin gap. -->
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape_compact"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
@@ -14,26 +14,92 @@
|
||||
<template id="report_fp_invoice_portrait">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<div class="fp-report">
|
||||
<div class="page">
|
||||
<t t-set="form_code" t-value="'FRM-007'"/>
|
||||
<t t-call="fusion_plating_reports.fp_external_layout_clean">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id or env.company"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
|
||||
<h4>
|
||||
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
|
||||
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
|
||||
<span t-field="doc.name"/>
|
||||
</h4>
|
||||
<!-- Compute helpers -->
|
||||
<t t-set="title_en" t-value="
|
||||
'Credit Note' if doc.move_type == 'out_refund'
|
||||
else 'Vendor Bill' if doc.move_type == 'in_invoice'
|
||||
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Invoice'"/>
|
||||
<t t-set="title_fr" t-value="
|
||||
'Note de crédit' if doc.move_type == 'out_refund'
|
||||
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
|
||||
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Facture'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
|
||||
<!-- Pull FP-specific fields from the source SO when invoice_origin
|
||||
names one. Falls back to an empty recordset on manual
|
||||
invoices so the t-if guards in the markup stay clean. -->
|
||||
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
|
||||
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
|
||||
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
|
||||
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
|
||||
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
|
||||
|
||||
<div class="fp-report fp-sale">
|
||||
<!-- Inline 3-column header: logo+address LEFT,
|
||||
NADCAP MIDDLE, title+barcode RIGHT.
|
||||
Mirrors SO confirmation exactly. -->
|
||||
<div class="fp-sale-header-row">
|
||||
<div class="fp-sale-header-left">
|
||||
<t t-if="logo_uri">
|
||||
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
|
||||
</t>
|
||||
<div class="fp-sale-company-addr">
|
||||
<div>
|
||||
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
|
||||
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
|
||||
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
|
||||
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
|
||||
</div>
|
||||
<div t-if="company.phone or company_fax">
|
||||
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
|
||||
<t t-if="company.phone and company_fax">   </t>
|
||||
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
|
||||
</div>
|
||||
<div t-if="company.partner_id.website">
|
||||
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-sale-header-mid">
|
||||
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
|
||||
<img class="fp-nadcap-logo"
|
||||
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
|
||||
alt="Nadcap Accredited"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fp-sale-header-right">
|
||||
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Billing / Shipping -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
||||
<th style="width: 50%;">DELIVERY ADDRESS</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
|
||||
</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -56,53 +122,131 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Invoice info -->
|
||||
<!-- Row 1: Invoice Date | Due Date | Sales Rep | Customer PO # | Payment Ref -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 25%;">INVOICE DATE</th>
|
||||
<th class="info-header" style="width: 25%;">DUE DATE</th>
|
||||
<th class="info-header" style="width: 25%;">SOURCE</th>
|
||||
<th class="info-header" style="width: 25%;">SALES REP</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Invoice Date</span>
|
||||
<span class="fp-bl-fr-stk">Date de facture</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Due Date</span>
|
||||
<span class="fp-bl-fr-stk">Date d'échéance</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Sales Rep</span>
|
||||
<span class="fp-bl-fr-stk">Vendeur</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Customer PO #</span>
|
||||
<span class="fp-bl-fr-stk">N° de B/C client</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Payment Ref</span>
|
||||
<span class="fp-bl-fr-stk">Réf. paiement</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.invoice_date"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
|
||||
<td class="text-center"><span t-esc="po_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Lines -->
|
||||
<!-- Row 2: Customer Job # | Specification | Delivery Method (pulled from source SO; hidden on manual invoices). -->
|
||||
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 34%;">
|
||||
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 33%;">
|
||||
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 33%;">
|
||||
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Lines (taxes column dropped; summarized in totals) -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 20%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 32%;">DESCRIPTION</th>
|
||||
<th style="width: 8%;">QTY</th>
|
||||
<th style="width: 8%;">UOM</th>
|
||||
<th style="width: 12%;">UNIT PRICE</th>
|
||||
<th style="width: 8%;">TAXES</th>
|
||||
<th style="width: 12%;">AMOUNT</th>
|
||||
<th class="text-start" style="width: 24%;">
|
||||
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th class="text-start" style="width: 38%;">
|
||||
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
|
||||
</th>
|
||||
<th style="width: 8%;">
|
||||
<span class="fp-bl-en-stk">Qty</span>
|
||||
<span class="fp-bl-fr-stk">Qté</span>
|
||||
</th>
|
||||
<th style="width: 8%;">
|
||||
<span class="fp-bl-en-stk">UOM</span>
|
||||
<span class="fp-bl-fr-stk">UDM</span>
|
||||
</th>
|
||||
<th style="width: 11%;">
|
||||
<span class="fp-bl-en-stk">Unit Price</span>
|
||||
<span class="fp-bl-fr-stk">Prix unitaire</span>
|
||||
</th>
|
||||
<th style="width: 11%;">
|
||||
<span class="fp-bl-en-stk">Amount</span>
|
||||
<span class="fp-bl-fr-stk">Montant</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.invoice_line_ids" t-as="line">
|
||||
<t t-if="line.display_type == 'line_section'">
|
||||
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
|
||||
<tr class="section-row"><td colspan="6"><strong t-field="line.name"/></td></tr>
|
||||
</t>
|
||||
<t t-elif="line.display_type == 'line_note'">
|
||||
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
|
||||
<tr class="note-row"><td colspan="6"><span t-field="line.name"/></td></tr>
|
||||
</t>
|
||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
<!-- Three stacked lines: Part #, Name, S/N -->
|
||||
<div>
|
||||
<strong>Part #:</strong>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Name:</strong>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
<div>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||
<span t-esc="line.x_fc_serial_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Suppress duplicate serial in the description column -->
|
||||
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@@ -112,9 +256,6 @@
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
@@ -127,40 +268,47 @@
|
||||
<!-- Terms + Totals -->
|
||||
<div class="row" style="margin-top: 15px;">
|
||||
<div class="col-6">
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<strong>Payment Terms:</strong><br/>
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-if="doc.payment_reference">
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Payment Reference:</strong>
|
||||
<span t-field="doc.payment_reference"/>
|
||||
</div>
|
||||
<t t-if="doc.invoice_payment_term_id">
|
||||
<strong>Payment Terms / Modalités de paiement:</strong><br/>
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="doc.invoice_payment_term_id.name"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="col-6" style="text-align: right;">
|
||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="min-width: 150px;">Subtotal</td>
|
||||
<td style="min-width: 150px;">
|
||||
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
|
||||
</td>
|
||||
<td class="text-end" style="min-width: 110px;">
|
||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Taxes</td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="background-color: #c1c1c1;">
|
||||
<td><strong>Grand Total</strong></td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
</tr>
|
||||
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
||||
<tr>
|
||||
<td><strong>Amount Due</strong></td>
|
||||
<td>
|
||||
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
@@ -173,14 +321,14 @@
|
||||
<!-- Paid stamp -->
|
||||
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
||||
<div style="margin-top: 15px; text-align: center;">
|
||||
<span class="paid-stamp">PAID</span>
|
||||
<span class="paid-stamp">PAID / PAYÉ</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Notes -->
|
||||
<t t-if="doc.narration">
|
||||
<div style="margin-top: 15px;">
|
||||
<strong>Notes:</strong>
|
||||
<strong>Notes / Remarques:</strong>
|
||||
<div t-field="doc.narration"/>
|
||||
</div>
|
||||
</t>
|
||||
@@ -198,26 +346,87 @@
|
||||
<template id="report_fp_invoice_landscape">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
|
||||
<div class="fp-landscape">
|
||||
<div class="page">
|
||||
<t t-set="form_code" t-value="'FRM-007'"/>
|
||||
<t t-call="fusion_plating_reports.fp_external_layout_clean">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id or env.company"/>
|
||||
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
|
||||
<h2 style="text-align: left;">
|
||||
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
|
||||
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
|
||||
<span t-field="doc.name"/>
|
||||
</h2>
|
||||
<!-- Same compute helpers as portrait -->
|
||||
<t t-set="title_en" t-value="
|
||||
'Credit Note' if doc.move_type == 'out_refund'
|
||||
else 'Vendor Bill' if doc.move_type == 'in_invoice'
|
||||
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Invoice'"/>
|
||||
<t t-set="title_fr" t-value="
|
||||
'Note de crédit' if doc.move_type == 'out_refund'
|
||||
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
|
||||
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Facture'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
|
||||
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
|
||||
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
|
||||
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
|
||||
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
|
||||
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
|
||||
|
||||
<div class="fp-landscape fp-sale">
|
||||
<!-- 3-column inline header — same as portrait -->
|
||||
<div class="fp-sale-header-row">
|
||||
<div class="fp-sale-header-left">
|
||||
<t t-if="logo_uri">
|
||||
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
|
||||
</t>
|
||||
<div class="fp-sale-company-addr">
|
||||
<div>
|
||||
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
|
||||
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
|
||||
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
|
||||
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
|
||||
</div>
|
||||
<div t-if="company.phone or company_fax">
|
||||
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
|
||||
<t t-if="company.phone and company_fax">   </t>
|
||||
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
|
||||
</div>
|
||||
<div t-if="company.partner_id.website">
|
||||
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-sale-header-mid">
|
||||
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
|
||||
<img class="fp-nadcap-logo"
|
||||
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
|
||||
alt="Nadcap Accredited"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fp-sale-header-right">
|
||||
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Billing / Shipping -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
||||
<th style="width: 50%;">DELIVERY ADDRESS</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
|
||||
</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -240,44 +449,106 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Invoice info (wide) -->
|
||||
<!-- Invoice info row (wide, 6 cols on landscape) -->
|
||||
<table class="bordered info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>INVOICE DATE</th>
|
||||
<th>DUE DATE</th>
|
||||
<th>SOURCE</th>
|
||||
<th>SALES REP</th>
|
||||
<th>PAYMENT REF</th>
|
||||
<th>CURRENCY</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Invoice Date</span>
|
||||
<span class="fp-bl-fr-stk">Date de facture</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Due Date</span>
|
||||
<span class="fp-bl-fr-stk">Date d'échéance</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Sales Rep</span>
|
||||
<span class="fp-bl-fr-stk">Vendeur</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Customer PO #</span>
|
||||
<span class="fp-bl-fr-stk">N° de B/C client</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Payment Ref</span>
|
||||
<span class="fp-bl-fr-stk">Réf. paiement</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Currency</span>
|
||||
<span class="fp-bl-fr-stk">Devise</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.invoice_date"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
|
||||
<td class="text-center"><span t-esc="doc.payment_reference or '-'"/></td>
|
||||
<td class="text-center"><span t-esc="po_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
|
||||
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Lines — hide discount column unless at least one line has a discount -->
|
||||
<!-- Source SO row (wider, 3 cols, inline). Hidden on manual invoices. -->
|
||||
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
|
||||
<table class="bordered info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Lines (taxes column dropped; discount column conditional) -->
|
||||
<t t-set="has_discount" t-value="any(l.discount for l in doc.invoice_line_ids)"/>
|
||||
<t t-set="col_count" t-value="8 if has_discount else 7"/>
|
||||
<t t-set="col_count" t-value="7 if has_discount else 6"/>
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 18%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 24%;">DESCRIPTION</th>
|
||||
<th style="width: 8%;">QTY</th>
|
||||
<th style="width: 8%;">UOM</th>
|
||||
<th style="width: 12%;">UNIT PRICE</th>
|
||||
<th t-if="has_discount" style="width: 10%;">DISCOUNT</th>
|
||||
<th style="width: 10%;">TAXES</th>
|
||||
<th style="width: 10%;">AMOUNT</th>
|
||||
<th class="text-start" style="width: 22%;">
|
||||
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th class="text-start" style="width: 30%;">
|
||||
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
|
||||
</th>
|
||||
<th style="width: 7%;">
|
||||
<span class="fp-bl-en-stk">Qty</span>
|
||||
<span class="fp-bl-fr-stk">Qté</span>
|
||||
</th>
|
||||
<th style="width: 7%;">
|
||||
<span class="fp-bl-en-stk">UOM</span>
|
||||
<span class="fp-bl-fr-stk">UDM</span>
|
||||
</th>
|
||||
<th style="width: 12%;">
|
||||
<span class="fp-bl-en-stk">Unit Price</span>
|
||||
<span class="fp-bl-fr-stk">Prix unitaire</span>
|
||||
</th>
|
||||
<th t-if="has_discount" style="width: 10%;">
|
||||
<span class="fp-bl-en-stk">Discount</span>
|
||||
<span class="fp-bl-fr-stk">Remise</span>
|
||||
</th>
|
||||
<th style="width: 12%;">
|
||||
<span class="fp-bl-en-stk">Amount</span>
|
||||
<span class="fp-bl-fr-stk">Montant</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -291,9 +562,28 @@
|
||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
<!-- Three stacked lines: Part #, Name, S/N -->
|
||||
<div>
|
||||
<strong>Part #:</strong>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Name:</strong>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
<div>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||
<span t-esc="line.x_fc_serial_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@@ -305,10 +595,7 @@
|
||||
</td>
|
||||
<td t-if="has_discount" class="text-center">
|
||||
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
|
||||
<t t-else="">—</t>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
@@ -322,34 +609,47 @@
|
||||
<!-- Terms + Totals -->
|
||||
<div class="row" style="margin-top: 15px;">
|
||||
<div class="col-7">
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<strong>Payment Terms:</strong><br/>
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
<t t-if="doc.invoice_payment_term_id">
|
||||
<strong>Payment Terms / Modalités de paiement:</strong><br/>
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="doc.invoice_payment_term_id.name"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="col-5" style="text-align: right;">
|
||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="min-width: 200px;">Subtotal</td>
|
||||
<td style="min-width: 200px;">
|
||||
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
|
||||
</td>
|
||||
<td class="text-end" style="min-width: 150px;">
|
||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Taxes</td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="background-color: #c1c1c1;">
|
||||
<td><strong>Grand Total</strong></td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
</tr>
|
||||
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
||||
<tr>
|
||||
<td><strong>Amount Due</strong></td>
|
||||
<td>
|
||||
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
@@ -362,7 +662,7 @@
|
||||
<!-- Paid stamp -->
|
||||
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
||||
<div style="margin-top: 15px; text-align: center;">
|
||||
<span class="paid-stamp">PAID</span>
|
||||
<span class="paid-stamp">PAID / PAYÉ</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -13,6 +13,25 @@
|
||||
<!-- ============================================================= -->
|
||||
<template id="fp_packing_slip_styles">
|
||||
<style>
|
||||
/* CoC pattern: paperformat_fp_a4_portrait reserves only
|
||||
margin_top=8mm; the body padding-top clears the header
|
||||
band. Same trick as .fp-coc and .fp-sale. */
|
||||
.fp-report.fp-ps .page { padding-top: 20mm; }
|
||||
/* Title bar: float div layout (NO table — see CLAUDE.md
|
||||
wkhtmltopdf overlap §2). Stacked bilingual title with
|
||||
English bold on top and French italic-grey below. */
|
||||
.fp-ps-titlebar { margin: 0 0 10px 0; padding: 0; overflow: hidden; }
|
||||
.fp-ps-title-en { font-size: 18pt; font-weight: bold; color: #2e2e2e; line-height: 1.1; display: block; }
|
||||
.fp-ps-title-fr { font-size: 13pt; font-style: italic; color: #555; line-height: 1.1; display: block; margin-top: 2px; }
|
||||
.fp-ps-title-num { font-weight: bold; margin-left: 6px; }
|
||||
/* Code128 barcode block (top-right of title bar). Same
|
||||
sizing/centering as the SO confirmation; encodes the
|
||||
packing-slip number so the printed label matches the
|
||||
visible title. */
|
||||
.fp-ps-barcode { float: right; margin-left: 12px; }
|
||||
.fp-ps-barcode .fp-bc-wrap { display: inline-block; text-align: center; }
|
||||
.fp-ps-barcode img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
|
||||
.fp-ps-barcode .fp-bc-label { font-size: 10pt; color: #333; margin-top: 6px; letter-spacing: 0.5px; }
|
||||
.fp-ps-addrtable td { vertical-align: top; padding: 8px 10px; font-size: 10pt; }
|
||||
.fp-ps-addrtable .fp-ps-addr-label { font-weight: bold; font-size: 9pt; color: #333; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.fp-ps-info-table th { background-color: #eaeaea; }
|
||||
@@ -187,16 +206,49 @@
|
||||
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
|
||||
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
|
||||
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
|
||||
|
||||
<!-- Packing slip number derived from the SO so it
|
||||
matches the order/work order number: SO-30045
|
||||
→ "30045". When the SO has multiple outbound
|
||||
pickings (partial shipments), the 2nd onwards
|
||||
get a -NN suffix: "30045-02", "30045-03". The
|
||||
first/only picking is just the bare number.
|
||||
Falls back to picking name (WH/OUT/00168) for
|
||||
pickings without a linked SO. -->
|
||||
<t t-set="so_name_raw" t-value="doc.sale_id.name if doc.sale_id else doc.name"/>
|
||||
<t t-set="ps_base_num" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
|
||||
<t t-set="outbound_pickings" t-value="doc.sale_id.picking_ids.filtered(lambda p: p.picking_type_id and p.picking_type_id.code == 'outgoing') if doc.sale_id else doc"/>
|
||||
<t t-set="picking_position" t-value="(sorted(outbound_pickings.ids).index(doc.id) + 1) if (doc.sale_id and doc.id in outbound_pickings.ids) else 1"/>
|
||||
<t t-set="ps_number" t-value="ps_base_num if picking_position <= 1 else ('%s-%02d' % (ps_base_num, picking_position))"/>
|
||||
|
||||
<!-- Code128 barcode encoding the packing-slip number so
|
||||
the printed label matches the visible title. Inlined
|
||||
via barcode_data_uri (no /report/barcode/ HTTP fetch
|
||||
— wkhtmltopdf network calls fail on entech). -->
|
||||
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
|
||||
|
||||
<t t-set="qr_payload" t-value="doc.name or ''"/>
|
||||
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
|
||||
|
||||
<div class="fp-report">
|
||||
<div class="fp-report fp-ps">
|
||||
<div class="page">
|
||||
|
||||
<h4>
|
||||
Packing Slip #
|
||||
<span t-field="doc.name"/>
|
||||
</h4>
|
||||
<!-- Bilingual title (EN bold on top, FR
|
||||
italic-grey below) matching SO and CoC. -->
|
||||
<div class="fp-ps-titlebar">
|
||||
<t t-if="ps_barcode_uri">
|
||||
<div class="fp-ps-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
|
||||
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-ps-title-en">
|
||||
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
|
||||
</span>
|
||||
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
|
||||
</div>
|
||||
|
||||
<!-- Bill To / Ship To -->
|
||||
<table class="bordered fp-ps-addrtable">
|
||||
@@ -294,16 +346,40 @@
|
||||
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
|
||||
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
|
||||
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
|
||||
|
||||
<!-- See portrait template for the numbering logic. -->
|
||||
<t t-set="so_name_raw" t-value="doc.sale_id.name if doc.sale_id else doc.name"/>
|
||||
<t t-set="ps_base_num" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
|
||||
<t t-set="outbound_pickings" t-value="doc.sale_id.picking_ids.filtered(lambda p: p.picking_type_id and p.picking_type_id.code == 'outgoing') if doc.sale_id else doc"/>
|
||||
<t t-set="picking_position" t-value="(sorted(outbound_pickings.ids).index(doc.id) + 1) if (doc.sale_id and doc.id in outbound_pickings.ids) else 1"/>
|
||||
<t t-set="ps_number" t-value="ps_base_num if picking_position <= 1 else ('%s-%02d' % (ps_base_num, picking_position))"/>
|
||||
|
||||
<!-- Code128 barcode encoding the packing-slip number so
|
||||
the printed label matches the visible title. Inlined
|
||||
via barcode_data_uri (no /report/barcode/ HTTP fetch
|
||||
— wkhtmltopdf network calls fail on entech). -->
|
||||
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
|
||||
|
||||
<t t-set="qr_payload" t-value="doc.name or ''"/>
|
||||
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
|
||||
|
||||
<div class="fp-landscape">
|
||||
<div class="fp-landscape fp-ps">
|
||||
<div class="page">
|
||||
|
||||
<h2 style="text-align: left;">
|
||||
Packing Slip #
|
||||
<span t-field="doc.name"/>
|
||||
</h2>
|
||||
<div class="fp-ps-titlebar">
|
||||
<t t-if="ps_barcode_uri">
|
||||
<div class="fp-ps-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
|
||||
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-ps-title-en">
|
||||
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
|
||||
</span>
|
||||
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
|
||||
</div>
|
||||
|
||||
<!-- Bill To / Ship To -->
|
||||
<table class="bordered fp-ps-addrtable">
|
||||
|
||||
@@ -22,6 +22,46 @@
|
||||
External_layout already places the page body at the bottom of
|
||||
the reserved margin-top — don't fight that. Use a small positive
|
||||
gap and shrink the title text instead. -->
|
||||
<!-- Custom minimal layout — same .article wrapper that Odoo's
|
||||
report pipeline expects (so UTF-8 charset handling works
|
||||
correctly via the standard processing path), but with NO
|
||||
auto company .header div. Includes a minimal .footer div
|
||||
that ONLY carries the wkhtmltopdf page-number placeholders
|
||||
(`<span class="page"/> / <span class="topage"/>`) — those
|
||||
only get filled in when the .footer div is extracted into
|
||||
wkhtmltopdf's footer-html stream. The .footer is otherwise
|
||||
empty so no boilerplate company info shows. -->
|
||||
<template id="fp_external_layout_clean">
|
||||
<t t-if="not o" t-set="o" t-value="doc"/>
|
||||
<t t-if="not company">
|
||||
<t t-if="company_id">
|
||||
<t t-set="company" t-value="company_id"/>
|
||||
</t>
|
||||
<t t-elif="o and 'company_id' in o and o.company_id.sudo()">
|
||||
<t t-set="company" t-value="o.company_id.sudo()"/>
|
||||
</t>
|
||||
<t t-else="else">
|
||||
<t t-set="company" t-value="res_company"/>
|
||||
</t>
|
||||
</t>
|
||||
<div class="article o_report_layout_standard"
|
||||
t-att-data-oe-model="o and o._name"
|
||||
t-att-data-oe-id="o and o.id"
|
||||
t-att-data-oe-lang="o and o.env.context.get('lang')">
|
||||
<t t-out="0"/>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div style="font-size: 9pt; color: #666; overflow: hidden;">
|
||||
<!-- Internal form code (e.g. "FRM-006") on the left.
|
||||
Each report sets `form_code` via t-set BEFORE the
|
||||
t-call to this layout; reports that don't set it
|
||||
leave the left side blank. -->
|
||||
<span style="float: left;" t-if="form_code"><t t-esc="form_code"/></span>
|
||||
<span style="float: right; white-space: nowrap; padding-left: 10px;" t-if="report_type == 'pdf'">Page <span class="page"/> / <span class="topage"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="fp_sale_bilingual_styles">
|
||||
<style>
|
||||
/* Inline bilingual: English bold, then a faint slash, then
|
||||
@@ -38,13 +78,58 @@
|
||||
below in italic-grey. */
|
||||
.fp-bl-en-stk { display: block; font-weight: bold; }
|
||||
.fp-bl-fr-stk { display: block; font-weight: normal; font-style: italic; color: #555; font-size: 80%; margin-top: 1px; }
|
||||
/* Match the CoC pattern exactly: tiny paperformat margin_top
|
||||
(8mm) lets the header HTML overflow into the body area,
|
||||
and this 20mm padding-top on the wrapper clears it. See
|
||||
CLAUDE.md "wkhtmltopdf header overlap" — the alternative
|
||||
"size margin_top to the header height" approach has zero
|
||||
slack and breaks any time the header HTML grows. */
|
||||
.fp-report.fp-sale { padding-top: 20mm; }
|
||||
/* This template uses fp_external_layout_clean (sibling
|
||||
template in this file) instead of web.external_layout.
|
||||
That gives us the `.article` wrapper Odoo's report
|
||||
renderer needs for proper UTF-8 dispatch, WITHOUT the
|
||||
`.header` / `.footer` divs that wkhtmltopdf would
|
||||
extract into page-margin streams. So the body owns the
|
||||
entire visible header (logo + address LEFT, title +
|
||||
barcode RIGHT) and no auto company band shows up. See
|
||||
CLAUDE.md "Custom-header reports need .article wrapper". */
|
||||
.fp-report.fp-sale { padding-top: 0; }
|
||||
|
||||
/* Custom inline header: 2-column flex-via-floats. Left has
|
||||
the company logo + address + phone + URL; right has the
|
||||
document title (bilingual stack) + Code128 barcode. */
|
||||
.fp-sale-header-row { overflow: hidden; margin-bottom: 14px; padding-bottom: 6px; }
|
||||
.fp-sale-header-left { float: left; width: 38%; }
|
||||
/* Middle column: NADCAP accreditation logo (pulled from
|
||||
company settings) above a small "25 Years in Business"
|
||||
medallion. Conditional on company.x_fc_nadcap_active so
|
||||
non-Nadcap shops only see the badge. */
|
||||
.fp-sale-header-mid { float: left; width: 24%; text-align: center; padding-top: 4px; }
|
||||
.fp-nadcap-logo { max-height: 45px; max-width: 115px; display: inline-block; }
|
||||
/* 25-Years-in-Business medallion: tasteful gold-on-cream
|
||||
bordered badge. Picks a dark muted gold (#8a6d2c) so it
|
||||
reads as "anniversary keepsake" rather than gaudy. */
|
||||
.fp-25-badge {
|
||||
display: inline-block;
|
||||
border: 1.5px solid #c8a55b;
|
||||
background-color: #faf5e8;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.fp-25-badge .fp-25-num { font-size: 22pt; font-weight: bold; color: #8a6d2c; }
|
||||
.fp-25-badge .fp-25-lbl { font-size: 7pt; font-weight: bold; color: #8a6d2c; letter-spacing: 1.2px; margin-top: 1px; }
|
||||
.fp-25-badge .fp-25-sub { font-size: 6.5pt; color: #8a6d2c; font-style: italic; margin-top: 1px; }
|
||||
/* Right column: title (English bold + French italic) + barcode
|
||||
+ SO number all centered as a stacked block. Width reduced
|
||||
to 38% to share row with the new middle NADCAP+25Years cell. */
|
||||
.fp-sale-header-right { float: right; width: 38%; text-align: center; }
|
||||
.fp-sale-logo { max-height: 50px; max-width: 280px; display: block; margin-bottom: 4px; }
|
||||
.fp-sale-company-addr { font-size: 8.5pt; color: #222; line-height: 1.35; }
|
||||
.fp-sale-company-addr div { margin: 0; }
|
||||
.fp-sale-company-addr a { color: #2e6da4; text-decoration: none; }
|
||||
|
||||
/* Inline footer line — phone | email | website | tax id.
|
||||
One-time render at the bottom of page 1 (multi-page SO
|
||||
reports are rare; if we ever need it, switch to
|
||||
wkhtmltopdf --footer-html). */
|
||||
.fp-sale-customfooter { text-align: center; font-size: 8pt; color: #666; margin-top: 24px; padding-top: 8px; border-top: 1px solid #ccc; }
|
||||
/* Title bar uses float-based div layout, NOT an HTML table —
|
||||
the global ".fp-report table" rule was applying borders
|
||||
to every nested table even with "border: 0 !important",
|
||||
@@ -65,48 +150,83 @@
|
||||
.fp-sale-barcode { float: right; margin-left: 12px; }
|
||||
.fp-bc-wrap { display: inline-block; text-align: center; }
|
||||
.fp-bc-wrap img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
|
||||
.fp-bc-wrap .fp-bc-label { font-size: 10pt; color: #333; margin-top: 6px; letter-spacing: 0.5px; }
|
||||
.fp-bc-wrap .fp-bc-label { font-size: 16pt; font-weight: bold; color: #000; margin-top: 6px; letter-spacing: 1.5px; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<template id="report_fp_sale_portrait">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
<!-- Internal form code rendered on the footer left side
|
||||
by fp_external_layout_clean (see that template). -->
|
||||
<t t-set="form_code" t-value="'FRM-006'"/>
|
||||
<t t-call="fusion_plating_reports.fp_external_layout_clean">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id or env.company"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
|
||||
<!-- Compute helpers -->
|
||||
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
|
||||
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
|
||||
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
|
||||
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
|
||||
<!-- Compute helpers -->
|
||||
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
|
||||
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
|
||||
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
|
||||
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
|
||||
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
|
||||
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
|
||||
|
||||
<div class="fp-report fp-sale">
|
||||
<div class="page">
|
||||
|
||||
<!-- Title bar: stacked English/French title
|
||||
on the left, Code128 barcode floated
|
||||
right. NO HTML table — see CLAUDE.md
|
||||
"wkhtmltopdf header overlap" §2 for why
|
||||
a table here leaks borders. -->
|
||||
<div class="fp-sale-titlebar">
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-sale-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="barcode_uri" alt="Order Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-sale-title-en">
|
||||
<t t-esc="title_en"/><span class="fp-sale-title-num"># <span t-field="doc.name"/></span>
|
||||
</span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<div class="fp-report fp-sale">
|
||||
<!-- Inline header (drops web.external_layout for this
|
||||
report — see CSS comment for context). Left: logo
|
||||
+ address + tel/fax + URL. Right: bilingual title
|
||||
+ Code128 barcode of the order number. -->
|
||||
<div class="fp-sale-header-row">
|
||||
<div class="fp-sale-header-left">
|
||||
<t t-if="logo_uri">
|
||||
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
|
||||
</t>
|
||||
<div class="fp-sale-company-addr">
|
||||
<div>
|
||||
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
|
||||
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
|
||||
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
|
||||
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
|
||||
</div>
|
||||
<div t-if="company.phone or company_fax">
|
||||
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
|
||||
<t t-if="company.phone and company_fax">   </t>
|
||||
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
|
||||
</div>
|
||||
<div t-if="company.partner_id.website">
|
||||
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Middle column: NADCAP logo only. Base64-inlined
|
||||
from `company.x_fc_nadcap_logo` so wkhtmltopdf
|
||||
doesn't have to fetch over HTTP (network calls
|
||||
fail on entech). -->
|
||||
<div class="fp-sale-header-mid">
|
||||
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
|
||||
<img class="fp-nadcap-logo"
|
||||
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
|
||||
alt="Nadcap Accredited"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fp-sale-header-right">
|
||||
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||
<img t-att-src="barcode_uri" alt="Order Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Billing / Shipping (wide cells — inline) -->
|
||||
<table class="bordered">
|
||||
@@ -174,10 +294,18 @@
|
||||
<td class="text-center"><span t-field="doc.user_id"/></td>
|
||||
<td class="text-center"><span t-esc="doc.x_fc_po_number or '—'"/></td>
|
||||
<td class="text-center">
|
||||
<t t-if="doc.x_fc_rush_order">
|
||||
<span class="status-warning">Rush / Urgent</span>
|
||||
<!-- Lead Time renders from the
|
||||
computed display string on
|
||||
sale.order. Rush stays
|
||||
highlighted; everything
|
||||
else (range / single value
|
||||
/ Standard) is plain text. -->
|
||||
<t t-if="doc.x_fc_lead_time_display == 'Rush'">
|
||||
<span class="status-warning">Rush</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="doc.x_fc_lead_time_display"/>
|
||||
</t>
|
||||
<t t-else="">Standard</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -267,13 +395,34 @@
|
||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span> - </span>
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<!-- Three stacked lines:
|
||||
1. Part Number (catalog part_number + revision)
|
||||
2. Name (catalog name, falls back to em-dash)
|
||||
3. Serial Number (m2m serials joined, or em-dash) -->
|
||||
<div>
|
||||
<strong>Part #:</strong>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Name:</strong>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
<div>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="line.x_fc_serial_ids">
|
||||
<span t-esc="', '.join(line.x_fc_serial_ids.mapped('name'))"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Rebind `line` with fp_no_serial_in_desc=True so the
|
||||
shared description macro skips its Serial line — we
|
||||
already render S/N in the part-number cell above. -->
|
||||
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.26.2.0',
|
||||
'version': '19.0.27.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -62,6 +62,28 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
||||
# so tokens are resolved via bundle concatenation order).
|
||||
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
|
||||
# ---- Shared OWL services (Phase 1 — tablet redesign) ----
|
||||
# Registered ONCE in web.assets_backend; Odoo 19 auto-compiles
|
||||
# into BOTH bright and dark bundles via $o-webclient-color-scheme.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/workflow_chip.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/gate_viz.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
||||
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
|
||||
@@ -6,3 +6,4 @@ from . import shopfloor_controller
|
||||
from . import manager_controller
|
||||
from . import tank_status
|
||||
from . import move_controller
|
||||
from . import workspace_controller
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoints for the Job Workspace client action.
|
||||
|
||||
Surfaces a single fp.job + step list + workflow milestones + side-panel
|
||||
data (spec PDF, attachments, chatter) + action endpoints (hold, sign-off,
|
||||
milestone advance).
|
||||
|
||||
Endpoints:
|
||||
POST /fp/workspace/load — full payload for one fp.job
|
||||
POST /fp/workspace/hold — create quality.hold with photo
|
||||
POST /fp/workspace/sign_off — capture signature + finish step
|
||||
POST /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
|
||||
Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpWorkspaceController(http.Controller):
|
||||
"""JSON-RPC endpoints for the JobWorkspace OWL client action."""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/load — full workspace payload
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/load', type='jsonrpc', auth='user')
|
||||
def load(self, job_id):
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
_logger.warning("workspace/load: job %s not found", job_id)
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
|
||||
# ---- Workflow milestones ----------------------------------------
|
||||
all_states = env['fp.job.workflow.state'].search([], order='sequence, id')
|
||||
current = job.workflow_state_id
|
||||
passed_ids = set()
|
||||
for ws in all_states:
|
||||
passed_ids.add(ws.id)
|
||||
if ws.id == current.id:
|
||||
break
|
||||
workflow_states = [{
|
||||
'id': ws.id,
|
||||
'name': ws.name,
|
||||
'color': ws.color or 'grey',
|
||||
'sequence': ws.sequence or 0,
|
||||
'passed': ws.id in passed_ids,
|
||||
'is_current': ws.id == current.id,
|
||||
} for ws in all_states]
|
||||
|
||||
# ---- Steps ------------------------------------------------------
|
||||
steps = []
|
||||
for step in job.step_ids.sorted('sequence'):
|
||||
override = job.override_ids.filtered(
|
||||
lambda o, n=step.recipe_node_id: o.node_id.id == n.id
|
||||
) if 'override_ids' in job._fields else env['fp.job.node.override']
|
||||
steps.append({
|
||||
'id': step.id,
|
||||
'sequence': step.sequence,
|
||||
'sequence_display': (step.sequence or 0) // 10,
|
||||
'name': step.name or '',
|
||||
'kind': step.kind or 'other',
|
||||
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
|
||||
'state': step.state,
|
||||
'assigned_user_id': step.assigned_user_id.id or False,
|
||||
'assigned_user_name': step.assigned_user_id.name or '',
|
||||
'work_centre_name': step.work_centre_id.name or '',
|
||||
'duration_actual': step.duration_actual or 0,
|
||||
'duration_expected': step.duration_expected or 0,
|
||||
'date_started_iso': fp_format(
|
||||
env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
|
||||
) if step.date_started else '',
|
||||
'instructions': step.instructions or '',
|
||||
'thickness_target': step.thickness_target or 0,
|
||||
'thickness_uom': step.thickness_uom or '',
|
||||
'dwell_time_minutes': step.dwell_time_minutes or 0,
|
||||
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
|
||||
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
|
||||
'can_start': bool(step.can_start) if 'can_start' in step._fields else (
|
||||
step.state in ('ready', 'paused') and step.blocker_kind == 'none'
|
||||
),
|
||||
'blocker_kind': step.blocker_kind,
|
||||
'blocker_reason': step.blocker_reason or '',
|
||||
'blocker_jump_target_model': step.blocker_jump_target_model or '',
|
||||
'blocker_jump_target_id': step.blocker_jump_target_id or 0,
|
||||
'override_excluded': bool(override and not override.included),
|
||||
'quick_look_prompt_count': len(
|
||||
getattr(step, 'quick_look_prompt_ids', step.browse())
|
||||
),
|
||||
})
|
||||
|
||||
# ---- Spec + attachments + chatter -------------------------------
|
||||
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else False
|
||||
attachments = env['ir.attachment'].search([
|
||||
('res_model', '=', 'fp.job'),
|
||||
('res_id', '=', job.id),
|
||||
], limit=20)
|
||||
chatter = job.message_ids.filtered(
|
||||
lambda m: m.message_type in ('comment', 'notification')
|
||||
).sorted('date', reverse=True)[:10]
|
||||
|
||||
# ---- Required cert state ----------------------------------------
|
||||
try:
|
||||
needs = list(job._resolve_required_cert_types())
|
||||
except Exception:
|
||||
needs = []
|
||||
try:
|
||||
has_draft = bool(job._fp_has_draft_required_certs())
|
||||
except Exception:
|
||||
has_draft = False
|
||||
required_certs = {'needs': needs, 'has_draft': has_draft}
|
||||
|
||||
# ---- Active step (the one in_progress) --------------------------
|
||||
active = (
|
||||
job.active_step_id
|
||||
if 'active_step_id' in job._fields and job.active_step_id
|
||||
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'job': {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'display_wo_name': job.display_wo_name,
|
||||
'partner_name': job.partner_id.name or '',
|
||||
'product_name': job.product_id.display_name or '',
|
||||
'part_number': (
|
||||
job.part_catalog_id.part_number
|
||||
if 'part_catalog_id' in job._fields and job.part_catalog_id
|
||||
else ''
|
||||
),
|
||||
'qty': int(job.qty or 0),
|
||||
'qty_done': int(job.qty_done or 0),
|
||||
'qty_scrapped': int(job.qty_scrapped or 0),
|
||||
'date_deadline': fp_format(
|
||||
env, job.date_deadline, fmt='%Y-%m-%d',
|
||||
) if job.date_deadline else '',
|
||||
'state': job.state,
|
||||
'workflow_state': {
|
||||
'id': current.id,
|
||||
'name': current.name,
|
||||
'color': current.color or 'grey',
|
||||
} if current else None,
|
||||
'next_milestone_action': job.next_milestone_action or '',
|
||||
'next_milestone_label': job.next_milestone_label or '',
|
||||
'quality_hold_count': job.quality_hold_count or 0,
|
||||
'priority': job.priority or 'normal',
|
||||
},
|
||||
'workflow_states': workflow_states,
|
||||
'steps': steps,
|
||||
'active_step_id': active.id if active else False,
|
||||
'spec': {
|
||||
'id': spec.id,
|
||||
'name': spec.name,
|
||||
} if spec else None,
|
||||
'attachments': [
|
||||
{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'mimetype': a.mimetype or '',
|
||||
'url': f'/web/content/{a.id}',
|
||||
}
|
||||
for a in attachments
|
||||
],
|
||||
'chatter': [
|
||||
{
|
||||
'id': m.id,
|
||||
'author': m.author_id.name or 'System',
|
||||
'body': m.body or '',
|
||||
'date': fp_format(env, m.date, fmt='%Y-%m-%d %H:%M') if m.date else '',
|
||||
}
|
||||
for m in chatter
|
||||
],
|
||||
'required_certs': required_certs,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/hold — create a quality.hold from HoldComposer
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
|
||||
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
|
||||
part_ref='', step_id=None, mark_for_scrap=False,
|
||||
photo_data=None, photo_filename=None):
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
if not qty_on_hold or int(qty_on_hold) < 1:
|
||||
return {'ok': False, 'error': 'qty_on_hold must be at least 1'}
|
||||
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
hold_vals = {
|
||||
'part_ref': part_ref or '',
|
||||
'qty_on_hold': int(qty_on_hold),
|
||||
'qty_original': int(job.qty or 0),
|
||||
'hold_reason': reason or 'other',
|
||||
'description': description or '',
|
||||
'mark_for_scrap': bool(mark_for_scrap),
|
||||
}
|
||||
if 'x_fc_job_id' in Hold._fields:
|
||||
hold_vals['x_fc_job_id'] = job.id
|
||||
if step_id and 'x_fc_step_id' in Hold._fields:
|
||||
hold_vals['x_fc_step_id'] = int(step_id)
|
||||
|
||||
try:
|
||||
hold = Hold.create(hold_vals)
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/hold: create failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# Attach photo if provided (base64 string from the camera input).
|
||||
# Photo attach failure does NOT roll back the hold — log + continue.
|
||||
attachment_id = False
|
||||
if photo_data:
|
||||
try:
|
||||
att = env['ir.attachment'].create({
|
||||
'name': photo_filename or f'hold_{hold.id}.png',
|
||||
'datas': photo_data,
|
||||
'res_model': 'fusion.plating.quality.hold',
|
||||
'res_id': hold.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
attachment_id = att.id
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/hold: photo attach failed for hold %s", hold.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Hold %s created on job %s by uid %s, reason %s, qty %s",
|
||||
hold.name, job.name, env.uid, reason, qty_on_hold,
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'hold_id': hold.id,
|
||||
'hold_name': hold.name,
|
||||
'state': hold.state,
|
||||
'attachment_id': attachment_id,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri):
|
||||
env = request.env
|
||||
sig = (signature_data_uri or '').strip()
|
||||
if not sig:
|
||||
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required to finish this step.',
|
||||
}
|
||||
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it)
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
|
||||
try:
|
||||
env['ir.attachment'].create({
|
||||
'name': f'signature_{step.id}.png',
|
||||
'datas': sig,
|
||||
'res_model': 'fp.job.step',
|
||||
'res_id': step.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/sign_off: attachment failed for step %s", step.id,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save signature.'}
|
||||
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/sign_off: button_finish failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
|
||||
return {
|
||||
'ok': True,
|
||||
'step_id': step.id,
|
||||
'state': step.state,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
|
||||
def advance_milestone(self, job_id):
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
if not job.next_milestone_action:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'No milestone advance available — finish all steps first.',
|
||||
}
|
||||
try:
|
||||
job.action_advance_next_milestone()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/advance_milestone failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info(
|
||||
"Job %s milestone advanced by uid %s", job.name, env.uid,
|
||||
)
|
||||
job.invalidate_recordset([
|
||||
'workflow_state_id',
|
||||
'next_milestone_action',
|
||||
'next_milestone_label',
|
||||
])
|
||||
return {
|
||||
'ok': True,
|
||||
'workflow_state': {
|
||||
'id': job.workflow_state_id.id,
|
||||
'name': job.workflow_state_id.name,
|
||||
'color': job.workflow_state_id.color or 'grey',
|
||||
} if job.workflow_state_id else None,
|
||||
'next_milestone_action': job.next_milestone_action or '',
|
||||
'next_milestone_label': job.next_milestone_label or '',
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — GateViz (shared OWL service)
|
||||
//
|
||||
// "Can't start because…" explainer for fp.job.step blockers. Drives off
|
||||
// step.blocker_kind/reason from the backend compute. Used in:
|
||||
// • JobWorkspace step rows (inline)
|
||||
// • Manager Plant Board "Needs Worker" cards (badge form)
|
||||
//
|
||||
// Props:
|
||||
// canStart : Boolean — when true, renders nothing
|
||||
// blockerKind : String — predecessor/contract_review/
|
||||
// parts_not_received/racking_required/
|
||||
// manager_input/other
|
||||
// blockerReason : String — human-readable explanation
|
||||
// jumpTargetModel : String — optional model name for tap-to-jump
|
||||
// jumpTargetId : Number — optional record id for tap-to-jump
|
||||
// onJump : Function — called with {model, id} on Jump click
|
||||
// =============================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class GateViz extends Component {
|
||||
static template = "fusion_plating_shopfloor.GateViz";
|
||||
static props = {
|
||||
canStart: { type: Boolean, optional: false },
|
||||
blockerKind: { type: String, optional: true },
|
||||
blockerReason: { type: String, optional: true },
|
||||
jumpTargetModel: { type: String, optional: true },
|
||||
jumpTargetId: { type: Number, optional: true },
|
||||
onJump: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
get iconClass() {
|
||||
const map = {
|
||||
predecessor: "fa-lock",
|
||||
contract_review: "fa-file-text-o",
|
||||
parts_not_received: "fa-truck",
|
||||
racking_required: "fa-th-large",
|
||||
manager_input: "fa-user-md",
|
||||
};
|
||||
return map[this.props.blockerKind] || "fa-pause-circle";
|
||||
}
|
||||
|
||||
onJumpClick() {
|
||||
if (this.props.onJump && this.props.jumpTargetModel && this.props.jumpTargetId) {
|
||||
this.props.onJump({
|
||||
model: this.props.jumpTargetModel,
|
||||
id: this.props.jumpTargetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — HoldComposer (shared OWL service)
|
||||
//
|
||||
// Modal form to create a fusion.plating.quality.hold with reason picker,
|
||||
// qty split, optional photo, description, and mark-for-scrap toggle.
|
||||
// Calls /fp/workspace/hold; caller passes onCreated(res) to refresh.
|
||||
//
|
||||
// Mounted via the dialog service:
|
||||
// this.dialog.add(FpHoldComposer, {
|
||||
// jobId, stepId?, defaultQty, partRef, onCreated,
|
||||
// });
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
// Hold reasons kept here so the picker doesn't need a server roundtrip.
|
||||
// Keep in sync with the fusion.plating.quality.hold.hold_reason Selection.
|
||||
const HOLD_REASONS = [
|
||||
{ value: "dimensional", label: "Dimensional" },
|
||||
{ value: "thickness", label: "Thickness fail" },
|
||||
{ value: "plating_defect", label: "Plating defect" },
|
||||
{ value: "contamination", label: "Contamination" },
|
||||
{ value: "wrong_part", label: "Wrong part" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
export class FpHoldComposer extends Component {
|
||||
static template = "fusion_plating_shopfloor.HoldComposer";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function,
|
||||
jobId: { type: Number, optional: false },
|
||||
stepId: { type: [Number, Boolean], optional: true },
|
||||
defaultQty: { type: Number, optional: true },
|
||||
partRef: { type: String, optional: true },
|
||||
onCreated: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.reasons = HOLD_REASONS;
|
||||
this.state = useState({
|
||||
reason: "dimensional",
|
||||
qty: this.props.defaultQty || 1,
|
||||
description: "",
|
||||
photoDataUri: null,
|
||||
photoFilename: "",
|
||||
markForScrap: false,
|
||||
submitting: false,
|
||||
});
|
||||
}
|
||||
|
||||
onPhotoChange(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
// Strip "data:...;base64," prefix — backend expects raw base64
|
||||
const dataUri = e.target.result;
|
||||
const base64 = dataUri.split(",", 2)[1] || "";
|
||||
this.state.photoDataUri = base64;
|
||||
this.state.photoFilename = file.name;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async onSubmit() {
|
||||
if (!this.state.qty || this.state.qty < 1) {
|
||||
this.notification.add("Qty on hold must be at least 1", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/hold", {
|
||||
job_id: this.props.jobId,
|
||||
step_id: this.props.stepId || null,
|
||||
part_ref: this.props.partRef || "",
|
||||
reason: this.state.reason,
|
||||
qty_on_hold: this.state.qty,
|
||||
description: this.state.description || "",
|
||||
mark_for_scrap: this.state.markForScrap,
|
||||
photo_data: this.state.photoDataUri,
|
||||
photo_filename: this.state.photoFilename,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add(`Hold ${res.hold_name} created.`, { type: "success" });
|
||||
if (this.props.onCreated) this.props.onCreated(res);
|
||||
this.props.close();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Hold creation failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — KanbanCard (shared OWL service)
|
||||
//
|
||||
// Standard WO/step card used on:
|
||||
// • Shop Floor Landing kanban (station + all-plant modes)
|
||||
// • Manager Plant Board (Needs Worker + In Progress columns)
|
||||
// • Manager Workflow Funnel (compact density per stage)
|
||||
//
|
||||
// Embeds WorkflowChip + a blocker badge from the backend's step.blocker_kind.
|
||||
//
|
||||
// Props:
|
||||
// data : { job_id, display_wo_name, customer, part, qty,
|
||||
// qty_done, qty_scrapped, date_deadline, priority,
|
||||
// workflow_state, blocker_kind, blocker_reason,
|
||||
// current_step_id, work_center }
|
||||
// density : 'compact' | 'normal' (default 'normal')
|
||||
// showWorkflowChip : Boolean
|
||||
// showWorkcenter : Boolean
|
||||
// showAssignedTo : Boolean
|
||||
// onTap : Function(data) — called on card click
|
||||
// =============================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { WorkflowChip } from "./workflow_chip";
|
||||
|
||||
export class FpKanbanCard extends Component {
|
||||
static template = "fusion_plating_shopfloor.KanbanCard";
|
||||
static components = { WorkflowChip };
|
||||
static props = {
|
||||
data: { type: Object, optional: false },
|
||||
density: { type: String, optional: true },
|
||||
showWorkflowChip: { type: Boolean, optional: true },
|
||||
showWorkcenter: { type: Boolean, optional: true },
|
||||
showAssignedTo: { type: Boolean, optional: true },
|
||||
onTap: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
get isCompact() {
|
||||
return this.props.density === "compact";
|
||||
}
|
||||
|
||||
get progressPct() {
|
||||
const d = this.props.data;
|
||||
if (!d.qty || d.qty <= 0) return 0;
|
||||
return Math.min(100, Math.round((d.qty_done || 0) * 100 / d.qty));
|
||||
}
|
||||
|
||||
get priorityDot() {
|
||||
const p = this.props.data.priority;
|
||||
if (p === "rush") return "danger";
|
||||
if (p === "high") return "warning";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.props.onTap) this.props.onTap(this.props.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — SignaturePad (shared OWL service)
|
||||
//
|
||||
// Modal canvas signature capture. Returns dataURI via onSubmit; the caller
|
||||
// commits it (e.g. /fp/workspace/sign_off). Mounted via the dialog service:
|
||||
// this.dialog.add(FpSignaturePad, { title, contextLabel, onSubmit, onCancel })
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class FpSignaturePad extends Component {
|
||||
static template = "fusion_plating_shopfloor.SignaturePad";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function, // dialog service injects
|
||||
title: { type: String, optional: true },
|
||||
contextLabel: { type: String, optional: true },
|
||||
onSubmit: { type: Function, optional: false }, // (dataUri) => void
|
||||
onCancel: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.canvasRef = useRef("canvas");
|
||||
this.isDrawing = false;
|
||||
this.lastPoint = null;
|
||||
this.hasInk = false;
|
||||
|
||||
this._onDown = (ev) => {
|
||||
this.isDrawing = true;
|
||||
this.lastPoint = this._localPoint(ev);
|
||||
};
|
||||
this._onMove = (ev) => {
|
||||
if (!this.isDrawing) return;
|
||||
const p = this._localPoint(ev);
|
||||
const ctx = this.canvasRef.el.getContext("2d");
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.lastPoint.x, this.lastPoint.y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
this.lastPoint = p;
|
||||
this.hasInk = true;
|
||||
};
|
||||
this._onUp = () => {
|
||||
this.isDrawing = false;
|
||||
this.lastPoint = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = this.canvasRef.el;
|
||||
// Match canvas pixel size to its CSS box so strokes don't stretch
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
ctx.strokeStyle = "#000";
|
||||
|
||||
canvas.addEventListener("pointerdown", this._onDown);
|
||||
canvas.addEventListener("pointermove", this._onMove);
|
||||
canvas.addEventListener("pointerup", this._onUp);
|
||||
canvas.addEventListener("pointercancel", this._onUp);
|
||||
canvas.addEventListener("pointerleave", this._onUp);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
const canvas = this.canvasRef.el;
|
||||
if (!canvas) return;
|
||||
canvas.removeEventListener("pointerdown", this._onDown);
|
||||
canvas.removeEventListener("pointermove", this._onMove);
|
||||
canvas.removeEventListener("pointerup", this._onUp);
|
||||
canvas.removeEventListener("pointercancel", this._onUp);
|
||||
canvas.removeEventListener("pointerleave", this._onUp);
|
||||
});
|
||||
}
|
||||
|
||||
_localPoint(ev) {
|
||||
const r = this.canvasRef.el.getBoundingClientRect();
|
||||
return { x: ev.clientX - r.left, y: ev.clientY - r.top };
|
||||
}
|
||||
|
||||
onClear() {
|
||||
const canvas = this.canvasRef.el;
|
||||
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
|
||||
this.hasInk = false;
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.hasInk) return;
|
||||
const dataUri = this.canvasRef.el.toDataURL("image/png");
|
||||
this.props.onSubmit(dataUri);
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
if (this.props.onCancel) this.props.onCancel();
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — WorkflowChip (shared OWL service)
|
||||
//
|
||||
// Renders an fp.job.workflow.state as a colored pill + optional next-action
|
||||
// hint. Used by KanbanCard, JobWorkspace header, Manager Funnel.
|
||||
//
|
||||
// Props:
|
||||
// state : { id, name, color } — required
|
||||
// nextActionLabel : string — optional
|
||||
//
|
||||
// Color map mirrors the fp.job.workflow.state.color Selection
|
||||
// (grey/blue/cyan/yellow/orange/green/success/danger/purple).
|
||||
// =============================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class WorkflowChip extends Component {
|
||||
static template = "fusion_plating_shopfloor.WorkflowChip";
|
||||
static props = {
|
||||
state: { type: Object, optional: false },
|
||||
nextActionLabel: { type: String, optional: true },
|
||||
};
|
||||
|
||||
get toneClass() {
|
||||
const map = {
|
||||
grey: "muted",
|
||||
blue: "info",
|
||||
cyan: "info",
|
||||
yellow: "warning",
|
||||
orange: "warning",
|
||||
green: "success",
|
||||
success: "success",
|
||||
danger: "danger",
|
||||
purple: "info",
|
||||
};
|
||||
return map[this.props.state.color] || "muted";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Workspace (full-screen WO surface)
|
||||
// Client action: fp_job_workspace
|
||||
//
|
||||
// Opens from: kanban tap (Landing — Phase 3), smart button (fp.job form),
|
||||
// QR scan (FP-JOB/FP-STEP), manager dashboard card tap (Phase 4).
|
||||
//
|
||||
// Layout (top-to-bottom):
|
||||
// sticky header — WO #, customer, part, qty/done, deadline, holds
|
||||
// sticky workflow bar — 9-stage milestone dots + Next-action button
|
||||
// scrollable main:
|
||||
// left/center — step list with active expansion + GateViz
|
||||
// right side panel — spec PDF link + attachments + chatter
|
||||
// sticky action rail — Hold · Note · Milestone advance · Issue Cert
|
||||
//
|
||||
// Auto-refresh: every 15s.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { WorkflowChip } from "./components/workflow_chip";
|
||||
import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
|
||||
this.state = useState({
|
||||
data: null,
|
||||
jobId: null,
|
||||
focusStepId: null,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const params = (this.props.action && this.props.action.params) || {};
|
||||
this.state.jobId = params.job_id || null;
|
||||
this.state.focusStepId = params.focus_step_id || null;
|
||||
await this.refresh();
|
||||
this._refreshInterval = setInterval(() => this.refresh(), 15000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data refresh ------------------------------------------------------
|
||||
async refresh() {
|
||||
if (!this.state.jobId) return;
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
|
||||
if (res && res.ok) {
|
||||
this.state.data = res;
|
||||
} else if (res && res.error) {
|
||||
this.notification.add(res.error, { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Navigation --------------------------------------------------------
|
||||
onBack() {
|
||||
// Close workspace; return to whatever spawned the action
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
onJumpToBlocker({ model, id }) {
|
||||
// If the predecessor is in this same workspace, just scroll to it
|
||||
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
|
||||
if (inThisJob) {
|
||||
const el = document.querySelector(`[data-step-id="${id}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Step state helpers ------------------------------------------------
|
||||
iconForStepState(state) {
|
||||
const map = {
|
||||
ready: "○", paused: "⏸", in_progress: "▶",
|
||||
done: "✓", skipped: "✕", cancelled: "✕",
|
||||
};
|
||||
return map[state] || "○";
|
||||
}
|
||||
|
||||
isStepActive(step) {
|
||||
return step.state === "in_progress";
|
||||
}
|
||||
|
||||
// ---- Step actions ------------------------------------------------------
|
||||
async onStartStep(stepId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step started.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Start failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onFinishStep(step) {
|
||||
if (step.requires_signoff) {
|
||||
this.dialog.add(FpSignaturePad, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: async (dataUri) => {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step signed off and finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Plain finish — no signature required
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/stop_wo", {
|
||||
workorder_id: step.id, finish: true,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Finish failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Action rail handlers ---------------------------------------------
|
||||
onCreateHold() {
|
||||
const job = this.state.data.job;
|
||||
const remaining = Math.max(1, (job.qty || 0) - (job.qty_done || 0));
|
||||
this.dialog.add(FpHoldComposer, {
|
||||
jobId: this.state.jobId,
|
||||
defaultQty: remaining,
|
||||
partRef: job.part_number || "",
|
||||
onCreated: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
async onAddNote() {
|
||||
const text = window.prompt("Add a note to this WO:");
|
||||
if (!text) return;
|
||||
try {
|
||||
// ORM call for message_post — keeps chatter behaviour identical
|
||||
// to back-office posts (handles HTML escaping, subscribers, etc.)
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job",
|
||||
method: "message_post",
|
||||
args: [[this.state.jobId]],
|
||||
kwargs: { body: text, message_type: "comment" },
|
||||
});
|
||||
this.notification.add("Note added.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onAdvanceMilestone() {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/advance_milestone", {
|
||||
job_id: this.state.jobId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Milestone advanced.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Advance failed", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
|
||||
@@ -0,0 +1,30 @@
|
||||
// =============================================================================
|
||||
// GateViz — "this step can't start because..." explainer
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_gate-bg-hex: rgba(255, 159, 10, 0.10);
|
||||
$_gate-border-hex: #ff9f0a;
|
||||
$_gate-text-hex: #b06600;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_gate-text-hex: #ffb84d !global;
|
||||
}
|
||||
|
||||
.o_fp_gate {
|
||||
background: $_gate-bg-hex;
|
||||
border-left: 3px solid $_gate-border-hex;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0 6px 6px 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
|
||||
.o_fp_gate_body { flex: 1; }
|
||||
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
|
||||
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
||||
.o_fp_gate_jump { flex-shrink: 0; }
|
||||
@@ -0,0 +1,21 @@
|
||||
// =============================================================================
|
||||
// HoldComposer — modal hold-create form
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_hc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_hc_row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_hc_row label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// =============================================================================
|
||||
// KanbanCard — standard WO/step card for Landing + Manager surfaces
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_kc-bg-hex: #ffffff;
|
||||
$_kc-border-hex: #d8dadd;
|
||||
$_kc-hover-hex: #f5f5f7;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_kc-bg-hex: #22262d !global;
|
||||
$_kc-border-hex: #424245 !global;
|
||||
$_kc-hover-hex: #2d3138 !global;
|
||||
}
|
||||
|
||||
.o_fp_kcard {
|
||||
background: $_kc-bg-hex;
|
||||
border: 1px solid $_kc-border-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover { background: $_kc-hover-hex; }
|
||||
}
|
||||
|
||||
.o_fp_kcard_compact { padding: 0.4rem 0.55rem; }
|
||||
|
||||
.o_fp_kcard_h1 {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-weight: 700; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_h2 {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_qty {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 0.7rem; color: var(--text-secondary, #777);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_due { color: var(--text-secondary, #999); }
|
||||
|
||||
.o_fp_kcard_bar {
|
||||
height: 4px; background: rgba(0,0,0,0.08);
|
||||
border-radius: 2px; overflow: hidden;
|
||||
margin-top: 0.3rem;
|
||||
|
||||
> div { height: 100%; background: #34c759; }
|
||||
}
|
||||
|
||||
.o_fp_kcard_chips {
|
||||
display: flex; gap: 0.35rem; flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_pri { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.o_fp_kcard_pri_danger { background: #ff3b30; }
|
||||
.o_fp_kcard_pri_warning { background: #ff9f0a; }
|
||||
.o_fp_kcard_pri_muted { background: transparent; }
|
||||
|
||||
.o_fp_kcard_blocked {
|
||||
background: rgba(255,159,10,0.15);
|
||||
color: #b06600;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_wc {
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_kcard_blocked { color: #ffb84d; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// =============================================================================
|
||||
// SignaturePad — modal canvas signature capture
|
||||
// Canvas stays light even in dark mode (signature legibility).
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_sig-canvas-bg-hex: #ffffff;
|
||||
$_sig-canvas-border-hex: #d8dadd;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_sig-canvas-bg-hex: #f5f5f5 !global;
|
||||
$_sig-canvas-border-hex: #5a5a5e !global;
|
||||
}
|
||||
|
||||
.o_fp_sig_pad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.o_fp_sig_canvas {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: $_sig-canvas-bg-hex;
|
||||
border: 2px solid $_sig-canvas-border-hex;
|
||||
border-radius: 6px;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.o_fp_sig_hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #999);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// =============================================================================
|
||||
// WorkflowChip — colored milestone pill
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch (registered in BOTH
|
||||
// web.assets_backend AND web.assets_web_dark — see manifest).
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_wf-bg-muted-hex: #f0f0f2;
|
||||
$_wf-bg-info-hex: rgba(0, 113, 227, 0.15);
|
||||
$_wf-bg-success-hex: rgba(52, 199, 89, 0.15);
|
||||
$_wf-bg-warning-hex: rgba(255, 159, 10, 0.15);
|
||||
$_wf-bg-danger-hex: rgba(255, 59, 48, 0.15);
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_wf-bg-muted-hex: #2d2d2f !global;
|
||||
}
|
||||
|
||||
.o_fp_wf_chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_wf_dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.o_fp_wf_next {
|
||||
font-weight: 400;
|
||||
opacity: 0.75;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
.o_fp_wf_chip_muted { background: $_wf-bg-muted-hex; color: #666; }
|
||||
.o_fp_wf_chip_info { background: $_wf-bg-info-hex; color: #0050a0; }
|
||||
.o_fp_wf_chip_success { background: $_wf-bg-success-hex; color: #1d6e2f; }
|
||||
.o_fp_wf_chip_warning { background: $_wf-bg-warning-hex; color: #b06600; }
|
||||
.o_fp_wf_chip_danger { background: $_wf-bg-danger-hex; color: #b00018; }
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_wf_chip_muted { color: #a8a8b0; }
|
||||
.o_fp_wf_chip_info { color: #6cb6ff; }
|
||||
.o_fp_wf_chip_success { color: #6be398; }
|
||||
.o_fp_wf_chip_warning { color: #ffb84d; }
|
||||
.o_fp_wf_chip_danger { color: #ff7a72; }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// =============================================================================
|
||||
// JobWorkspace — full-screen WO surface
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_ws-page-hex: #f3f4f6;
|
||||
$_ws-card-hex: #ffffff;
|
||||
$_ws-border-hex: #d8dadd;
|
||||
$_ws-text-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_ws-page-hex: #1a1d21 !global;
|
||||
$_ws-card-hex: #22262d !global;
|
||||
$_ws-border-hex: #424245 !global;
|
||||
$_ws-text-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_ws {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $_ws-page-hex;
|
||||
color: $_ws-text-hex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_ws_loading {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
|
||||
> div { margin-top: 0.6rem; }
|
||||
}
|
||||
|
||||
// ---- HEADER ------------------------------------------------------------
|
||||
.o_fp_ws_head {
|
||||
background: $_ws-card-hex;
|
||||
border-bottom: 1px solid $_ws-border-hex;
|
||||
padding: 0.6rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_ws_head_l, .o_fp_ws_head_r {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_ws_back { padding: 0.25rem 0.5rem; }
|
||||
.o_fp_ws_wo { font-weight: 700; font-size: 1.1rem; }
|
||||
.o_fp_ws_dot { color: var(--text-secondary, #999); }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); }
|
||||
|
||||
.o_fp_ws_pill {
|
||||
background: $_ws-page-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.o_fp_ws_holds_ok {
|
||||
background: rgba(52, 199, 89, 0.12);
|
||||
color: #1d6e2f;
|
||||
border-color: rgba(52, 199, 89, 0.3);
|
||||
}
|
||||
|
||||
.o_fp_ws_holds_red {
|
||||
background: rgba(255, 59, 48, 0.12);
|
||||
color: #b00018;
|
||||
border-color: rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
// ---- WORKFLOW BAR ------------------------------------------------------
|
||||
.o_fp_ws_bar {
|
||||
background: $_ws-page-hex;
|
||||
border-bottom: 1px solid $_ws-border-hex;
|
||||
padding: 0.55rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_bar_line {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_dot_wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
|
||||
.o_fp_ws_bar_dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $_ws-border-hex;
|
||||
border: 2px solid $_ws-border-hex;
|
||||
}
|
||||
|
||||
.o_fp_ws_bar_label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-top: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.done .o_fp_ws_bar_dot {
|
||||
background: #34c759;
|
||||
border-color: #34c759;
|
||||
}
|
||||
|
||||
&.current .o_fp_ws_bar_dot {
|
||||
background: #0071e3;
|
||||
border-color: #0071e3;
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.25);
|
||||
}
|
||||
|
||||
&.current .o_fp_ws_bar_label {
|
||||
color: #0071e3;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_link {
|
||||
flex: 0 0 8px;
|
||||
height: 2px;
|
||||
background: $_ws-border-hex;
|
||||
margin-top: -16px;
|
||||
|
||||
&.done { background: #34c759; }
|
||||
}
|
||||
|
||||
.o_fp_ws_next { white-space: nowrap; }
|
||||
|
||||
// ---- MAIN (step list + side panel) -------------------------------------
|
||||
.o_fp_ws_main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.7fr 1fr;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 900px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.o_fp_ws_steps {
|
||||
padding: 0.7rem 1rem;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid $_ws-border-hex;
|
||||
}
|
||||
|
||||
.o_fp_ws_side {
|
||||
padding: 0.7rem 1rem;
|
||||
overflow-y: auto;
|
||||
background: $_ws-page-hex;
|
||||
}
|
||||
|
||||
.o_fp_ws_empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-secondary, #999);
|
||||
|
||||
> div { margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
// ---- STEP ROW ---------------------------------------------------------
|
||||
.o_fp_ws_step {
|
||||
background: $_ws-card-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
margin-bottom: 0.4rem;
|
||||
|
||||
&.done { opacity: 0.7; }
|
||||
&.active {
|
||||
border: 2px solid #0071e3;
|
||||
padding: 0.6rem 0.75rem;
|
||||
box-shadow: 0 0 0 1px #0071e3;
|
||||
}
|
||||
&.blocked {
|
||||
background: rgba(255, 159, 10, 0.06);
|
||||
border-color: #ff9f0a;
|
||||
}
|
||||
&.excluded { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.o_fp_ws_step_l1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
|
||||
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
|
||||
.o_fp_ws_step_name { font-weight: 600; }
|
||||
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
|
||||
|
||||
.o_fp_ws_step_badge {
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_detail {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed $_ws-border-hex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
|
||||
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
|
||||
.o_fp_ws_step_excluded {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ---- Chips (inline mini-chips for recipe targets) ---------------------
|
||||
.o_fp_chip {
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.o_fp_chip_info { background: rgba(0, 113, 227, 0.12); color: #0050a0; }
|
||||
.o_fp_chip_warning { background: rgba(255, 159, 10, 0.15); color: #b06600; }
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_chip_info { color: #6cb6ff; }
|
||||
.o_fp_chip_warning { color: #ffb84d; }
|
||||
}
|
||||
|
||||
// ---- SIDE PANEL CARDS -------------------------------------------------
|
||||
.o_fp_ws_side_card {
|
||||
background: $_ws-card-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
margin-bottom: 0.45rem;
|
||||
|
||||
h4 {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary, #777);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_spec_link {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_attach {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.78rem;
|
||||
border-bottom: 1px dashed $_ws-border-hex;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.o_fp_ws_note {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.o_fp_ws_note_h {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_note .author { font-weight: 600; }
|
||||
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
|
||||
|
||||
.o_fp_ws_empty_small {
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ---- ACTION RAIL ------------------------------------------------------
|
||||
.o_fp_ws_rail {
|
||||
background: $_ws-card-hex;
|
||||
border-top: 1px solid $_ws-border-hex;
|
||||
padding: 0.55rem 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.GateViz">
|
||||
<div class="o_fp_gate" t-if="!props.canStart">
|
||||
<i t-att-class="'fa o_fp_gate_icon ' + iconClass"/>
|
||||
<div class="o_fp_gate_body">
|
||||
<div class="o_fp_gate_title">Can't start yet</div>
|
||||
<div class="o_fp_gate_reason">
|
||||
<t t-esc="props.blockerReason or 'Reason unknown — open the step in the back-office.'"/>
|
||||
</div>
|
||||
</div>
|
||||
<button t-if="props.jumpTargetModel and props.jumpTargetId and props.onJump"
|
||||
class="btn btn-sm btn-outline-warning o_fp_gate_jump"
|
||||
t-on-click="onJumpClick">
|
||||
Jump <i class="fa fa-arrow-right"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.HoldComposer">
|
||||
<Dialog title="'Place hold'" size="'md'">
|
||||
<div class="o_fp_hc">
|
||||
<div class="o_fp_hc_row">
|
||||
<label>Reason</label>
|
||||
<select class="form-select" t-model="state.reason">
|
||||
<t t-foreach="reasons" t-as="r" t-key="r.value">
|
||||
<option t-att-value="r.value"><t t-esc="r.label"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="o_fp_hc_row">
|
||||
<label>Qty on hold</label>
|
||||
<input type="number" min="1" class="form-control"
|
||||
t-model.number="state.qty"/>
|
||||
</div>
|
||||
<div class="o_fp_hc_row">
|
||||
<label>Description</label>
|
||||
<textarea class="form-control" rows="3" t-model="state.description"
|
||||
placeholder="What happened?"/>
|
||||
</div>
|
||||
<div class="o_fp_hc_row">
|
||||
<label>Photo (optional)</label>
|
||||
<input type="file" accept="image/*" capture="environment"
|
||||
class="form-control" t-on-change="onPhotoChange"/>
|
||||
<small t-if="state.photoFilename" class="text-success">
|
||||
<i class="fa fa-check"/> <t t-esc="state.photoFilename"/>
|
||||
</small>
|
||||
</div>
|
||||
<div class="o_fp_hc_row">
|
||||
<label class="form-check-label">
|
||||
<input type="checkbox" class="form-check-input me-1"
|
||||
t-model="state.markForScrap"/>
|
||||
Mark for scrap
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-link" t-on-click="() => this.props.close()">Cancel</button>
|
||||
<button class="btn btn-warning" t-on-click="onSubmit"
|
||||
t-att-disabled="state.submitting">
|
||||
<i class="fa fa-exclamation-triangle me-1"/> Create Hold
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.KanbanCard">
|
||||
<div t-att-class="'o_fp_kcard ' + (isCompact ? 'o_fp_kcard_compact' : '')"
|
||||
t-on-click="onClick">
|
||||
<div class="o_fp_kcard_h1">
|
||||
<span class="o_fp_kcard_wo"><t t-esc="props.data.display_wo_name"/></span>
|
||||
<span t-att-class="'o_fp_kcard_pri o_fp_kcard_pri_' + priorityDot"/>
|
||||
</div>
|
||||
<div class="o_fp_kcard_h2">
|
||||
<span t-esc="props.data.customer or ''"/>
|
||||
<t t-if="props.data.part"> · <t t-esc="props.data.part"/></t>
|
||||
</div>
|
||||
<div class="o_fp_kcard_qty" t-if="props.data.qty">
|
||||
<span><t t-esc="props.data.qty_done or 0"/> / <t t-esc="props.data.qty"/> done</span>
|
||||
<span t-if="props.data.date_deadline" class="o_fp_kcard_due">
|
||||
Due <t t-esc="props.data.date_deadline"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_kcard_bar" t-if="props.data.qty">
|
||||
<div t-att-style="'width: ' + progressPct + '%'"/>
|
||||
</div>
|
||||
<div class="o_fp_kcard_chips">
|
||||
<WorkflowChip t-if="props.showWorkflowChip and props.data.workflow_state"
|
||||
state="props.data.workflow_state"/>
|
||||
<span t-if="props.data.blocker_kind and props.data.blocker_kind !== 'none'"
|
||||
class="o_fp_kcard_blocked"
|
||||
t-att-title="props.data.blocker_reason">
|
||||
<i class="fa fa-lock"/> Blocked
|
||||
</span>
|
||||
<span t-if="props.showWorkcenter and props.data.work_center"
|
||||
class="o_fp_kcard_wc">
|
||||
@ <t t-esc="props.data.work_center"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.SignaturePad">
|
||||
<Dialog title="props.title or 'Signature required'" size="'md'">
|
||||
<div class="o_fp_sig_pad">
|
||||
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
|
||||
<t t-esc="props.contextLabel"/>
|
||||
</div>
|
||||
<canvas class="o_fp_sig_canvas" t-ref="canvas"/>
|
||||
<div class="o_fp_sig_hint">Draw your signature above</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-secondary" t-on-click="onClear">Clear</button>
|
||||
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" t-on-click="onSubmit">Sign & Finish</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.WorkflowChip">
|
||||
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + toneClass">
|
||||
<span class="o_fp_wf_dot"/>
|
||||
<span class="o_fp_wf_label" t-esc="props.state.name"/>
|
||||
<t t-if="props.nextActionLabel">
|
||||
<span class="o_fp_wf_next">· next: <t t-esc="props.nextActionLabel"/></span>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,230 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.JobWorkspace">
|
||||
<div class="o_fp_ws">
|
||||
|
||||
<!-- Loading state -->
|
||||
<div t-if="!state.data" class="o_fp_ws_loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div>Loading Job Workspace…</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.data">
|
||||
|
||||
<!-- =========================================================
|
||||
STICKY HEADER — WO context, qty bumps, workflow chip
|
||||
========================================================= -->
|
||||
<header class="o_fp_ws_head">
|
||||
<div class="o_fp_ws_head_l">
|
||||
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
|
||||
<i class="fa fa-arrow-left"/> Back
|
||||
</button>
|
||||
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
|
||||
<span class="o_fp_ws_dot"> · </span>
|
||||
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
|
||||
<t t-if="state.data.job.part_number">
|
||||
<span class="o_fp_ws_dot"> · </span>
|
||||
<span class="o_fp_ws_part"><t t-esc="state.data.job.part_number"/></span>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_fp_ws_head_r">
|
||||
<span class="o_fp_ws_pill">
|
||||
<t t-esc="state.data.job.qty_done"/> / <t t-esc="state.data.job.qty"/> done
|
||||
<t t-if="state.data.job.qty_scrapped">· <t t-esc="state.data.job.qty_scrapped"/> scrap</t>
|
||||
</span>
|
||||
<span class="o_fp_ws_pill" t-if="state.data.job.date_deadline">
|
||||
Due <t t-esc="state.data.job.date_deadline"/>
|
||||
</span>
|
||||
<WorkflowChip t-if="state.data.job.workflow_state"
|
||||
state="state.data.job.workflow_state"/>
|
||||
<span t-att-class="'o_fp_ws_pill ' + (state.data.job.quality_hold_count ? 'o_fp_ws_holds_red' : 'o_fp_ws_holds_ok')">
|
||||
<t t-esc="state.data.job.quality_hold_count"/> holds
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- =========================================================
|
||||
STICKY WORKFLOW BAR — milestone dots + Next button
|
||||
========================================================= -->
|
||||
<div class="o_fp_ws_bar">
|
||||
<div class="o_fp_ws_bar_line">
|
||||
<t t-foreach="state.data.workflow_states" t-as="ws" t-key="ws.id">
|
||||
<div t-att-class="'o_fp_ws_dot_wrap' + (ws.is_current ? ' current' : (ws.passed ? ' done' : ''))">
|
||||
<span class="o_fp_ws_bar_dot"/>
|
||||
<span class="o_fp_ws_bar_label" t-esc="ws.name"/>
|
||||
</div>
|
||||
<span t-if="!ws_last" t-att-class="'o_fp_ws_link' + (ws.passed ? ' done' : '')"/>
|
||||
</t>
|
||||
</div>
|
||||
<button t-if="state.data.job.next_milestone_action"
|
||||
class="btn btn-primary o_fp_ws_next"
|
||||
t-on-click="onAdvanceMilestone">
|
||||
Next: <t t-esc="state.data.job.next_milestone_label"/> <i class="fa fa-arrow-right"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
MAIN — step list (left/center) + side panel (right)
|
||||
========================================================= -->
|
||||
<div class="o_fp_ws_main">
|
||||
|
||||
<!-- STEP LIST -->
|
||||
<div class="o_fp_ws_steps">
|
||||
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
|
||||
<i class="fa fa-exclamation-circle fa-2x"/>
|
||||
<div>Recipe not generated for this WO.</div>
|
||||
</div>
|
||||
|
||||
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
|
||||
<div t-att-class="'o_fp_ws_step ' + step.state +
|
||||
(isStepActive(step) ? ' active' : '') +
|
||||
(step.override_excluded ? ' excluded' : '') +
|
||||
(step.blocker_kind !== 'none' ? ' blocked' : '')"
|
||||
t-att-data-step-id="step.id">
|
||||
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span class="o_fp_ws_step_meta">
|
||||
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
|
||||
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||
<div class="o_fp_ws_step_detail">
|
||||
<!-- Recipe chips (only on active step) -->
|
||||
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
|
||||
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
|
||||
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
|
||||
</span>
|
||||
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">
|
||||
⏱ Dwell <t t-esc="step.dwell_time_minutes"/> min
|
||||
</span>
|
||||
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">
|
||||
🔥 Bake <t t-esc="step.bake_setpoint_temp"/>°
|
||||
</span>
|
||||
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
|
||||
✎ Sign-off required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe author instructions (only on active step) -->
|
||||
<div t-if="step.instructions and isStepActive(step)"
|
||||
class="o_fp_ws_step_instr">
|
||||
<t t-esc="step.instructions"/>
|
||||
</div>
|
||||
|
||||
<!-- Opt-out notice -->
|
||||
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
||||
<i class="fa fa-ban"/> Skipped per recipe override for this WO
|
||||
</div>
|
||||
|
||||
<!-- Blocker explainer -->
|
||||
<GateViz t-if="step.blocker_kind !== 'none'"
|
||||
canStart="false"
|
||||
blockerKind="step.blocker_kind"
|
||||
blockerReason="step.blocker_reason"
|
||||
jumpTargetModel="step.blocker_jump_target_model"
|
||||
jumpTargetId="step.blocker_jump_target_id"
|
||||
onJump.bind="onJumpToBlocker"/>
|
||||
|
||||
<!-- Action buttons (only when unblocked) -->
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button t-if="step.requires_signoff"
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish & Sign Off
|
||||
</button>
|
||||
<button t-else=""
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onStartStep(step.id)">
|
||||
<i class="fa fa-play"/> Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- SIDE PANEL: spec + attachments + chatter -->
|
||||
<div class="o_fp_ws_side">
|
||||
|
||||
<div class="o_fp_ws_side_card" t-if="state.data.spec">
|
||||
<h4>Customer spec</h4>
|
||||
<div class="o_fp_ws_spec_link">
|
||||
<i class="fa fa-file-pdf-o"/>
|
||||
<a t-att-href="'/web/content/' + state.data.spec.id"
|
||||
target="_blank"><t t-esc="state.data.spec.name"/></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_ws_side_card" t-if="state.data.attachments.length">
|
||||
<h4>Drawings & attachments</h4>
|
||||
<t t-foreach="state.data.attachments" t-as="att" t-key="att.id">
|
||||
<div class="o_fp_ws_attach">
|
||||
<span>📄 <t t-esc="att.name"/></span>
|
||||
<a t-att-href="att.url" target="_blank">Open</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_ws_side_card">
|
||||
<h4>Notes</h4>
|
||||
<div t-if="!state.data.chatter.length" class="o_fp_ws_empty_small">
|
||||
No notes yet.
|
||||
</div>
|
||||
<t t-foreach="state.data.chatter" t-as="msg" t-key="msg.id">
|
||||
<div class="o_fp_ws_note">
|
||||
<div class="o_fp_ws_note_h">
|
||||
<span class="author"><t t-esc="msg.author"/></span>
|
||||
<span class="time"><t t-esc="msg.date"/></span>
|
||||
</div>
|
||||
<div class="body" t-out="msg.body"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
STICKY ACTION RAIL — Hold · Note · Cert · Milestone
|
||||
========================================================= -->
|
||||
<footer class="o_fp_ws_rail">
|
||||
<button class="btn btn-warning" t-on-click="onCreateHold">
|
||||
<i class="fa fa-exclamation-triangle"/> Create Hold
|
||||
</button>
|
||||
<button class="btn btn-light" t-on-click="onAddNote">
|
||||
<i class="fa fa-pencil"/> Note
|
||||
</button>
|
||||
<span style="flex: 1"/>
|
||||
<button t-if="state.data.required_certs.has_draft"
|
||||
class="btn btn-light"
|
||||
t-on-click="onAdvanceMilestone">
|
||||
<i class="fa fa-file-text"/> Issue Cert
|
||||
</button>
|
||||
<button t-if="state.data.job.next_milestone_action"
|
||||
class="btn btn-primary"
|
||||
t-on-click="onAdvanceMilestone">
|
||||
<i class="fa fa-arrow-right"/> <t t-esc="state.data.job.next_milestone_label"/>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_workspace_controller
|
||||
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""HTTP tests for /fp/workspace/* endpoints."""
|
||||
import base64
|
||||
import json
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
# Minimal 1x1 PNG so photo + signature attachment tests can run without
|
||||
# packing a real binary in the source tree.
|
||||
_TINY_PNG_B64 = base64.b64encode(
|
||||
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01'
|
||||
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
|
||||
).decode()
|
||||
|
||||
|
||||
def _rpc(case, url, **params):
|
||||
res = case.url_open(
|
||||
url,
|
||||
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return res.json()['result']
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceLoad(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'WS Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'WS Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/WS001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 5,
|
||||
})
|
||||
|
||||
def test_load_returns_full_payload(self):
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['job']['display_wo_name'], 'WO # WS001')
|
||||
self.assertEqual(res['job']['id'], self.job.id)
|
||||
for key in ('steps', 'workflow_states', 'chatter',
|
||||
'attachments', 'required_certs'):
|
||||
self.assertIn(key, res)
|
||||
|
||||
def test_load_bad_job_id_returns_error(self):
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=999999)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('not found', res['error'].lower())
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceHold(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'Hold Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Hold Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/H001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 10,
|
||||
})
|
||||
|
||||
def test_hold_creates_quality_hold(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/hold',
|
||||
job_id=self.job.id, reason='dimensional', qty_on_hold=3,
|
||||
description='Bracket bent on de-rack',
|
||||
part_ref='Bracket Rev A',
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
hold = self.env['fusion.plating.quality.hold'].browse(res['hold_id'])
|
||||
self.assertEqual(hold.qty_on_hold, 3)
|
||||
self.assertEqual(hold.hold_reason, 'dimensional')
|
||||
|
||||
def test_hold_with_photo_creates_attachment(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/hold',
|
||||
job_id=self.job.id, reason='thickness', qty_on_hold=1,
|
||||
photo_data=_TINY_PNG_B64, photo_filename='evidence.png',
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertTrue(res['attachment_id'])
|
||||
attachments = self.env['ir.attachment'].search([
|
||||
('res_model', '=', 'fusion.plating.quality.hold'),
|
||||
('res_id', '=', res['hold_id']),
|
||||
])
|
||||
self.assertGreaterEqual(len(attachments), 1)
|
||||
|
||||
def test_hold_qty_zero_rejected(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/hold',
|
||||
job_id=self.job.id, qty_on_hold=0,
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceSignOff(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/S001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
self.step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'ENP Plate',
|
||||
'sequence': 50,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
|
||||
def test_sign_off_rejects_empty_signature(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/sign_off',
|
||||
step_id=self.step.id, signature_data_uri='',
|
||||
)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('signature', res['error'].lower())
|
||||
|
||||
def test_sign_off_finishes_step(self):
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/sign_off',
|
||||
step_id=self.step.id, signature_data_uri=_TINY_PNG_B64,
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
self.step.invalidate_recordset(['state'])
|
||||
self.assertEqual(self.step.state, 'done')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceAdvanceMilestone(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'M Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'M Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/M001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_advance_no_action_returns_error(self):
|
||||
# Job with no steps → no next_milestone_action → friendly reject
|
||||
res = _rpc(self, '/fp/workspace/advance_milestone', job_id=self.job.id)
|
||||
self.assertFalse(res['ok'])
|
||||
Reference in New Issue
Block a user