Compare commits
62 Commits
b395600a1c
...
phase4-man
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cdb2e3d0b | ||
|
|
f00dda2abd | ||
|
|
3b7b2477cf | ||
|
|
e762ee4b68 | ||
|
|
5d086c7f27 | ||
|
|
3eba80bb31 | ||
|
|
2a0d1862df | ||
|
|
7f70785b79 | ||
|
|
9dcd00d9b2 | ||
|
|
5a28c7e90f | ||
|
|
3c2efae951 | ||
|
|
c06d3d442a | ||
|
|
c76eb94724 | ||
|
|
06dc6a62b9 | ||
|
|
5463efcfc2 | ||
|
|
3fdbeed813 | ||
|
|
a18ef6c405 | ||
|
|
eae6a471e8 | ||
|
|
a61bd05a5c | ||
|
|
8109b3ec76 | ||
|
|
9d78bc4317 | ||
|
|
5c3c979f77 | ||
|
|
b52fe01d07 | ||
|
|
81da9bf71c | ||
|
|
1d04ac8cb7 | ||
|
|
27465cfeac | ||
|
|
fb5da1e3cd | ||
|
|
f661724c72 | ||
|
|
d127e19b45 | ||
|
|
d022e529d9 | ||
|
|
894eea7ce2 | ||
|
|
612394c987 | ||
|
|
48dd7718e2 | ||
|
|
ecca8e357f | ||
|
|
f41426c5b9 | ||
|
|
ebbadb3002 | ||
|
|
4f1b7c2df6 | ||
|
|
b4b59cc3c9 | ||
|
|
638b223d3b | ||
|
|
f463600585 | ||
|
|
bf4464ba37 | ||
|
|
65c4d8801c | ||
|
|
ef0c096e48 | ||
|
|
c506b53dec | ||
|
|
d93b500901 | ||
|
|
5c8768c556 | ||
|
|
3a15164605 | ||
|
|
194850e3cf | ||
|
|
d15d9e4303 | ||
|
|
7f8a80fecb | ||
|
|
38a79a4b04 | ||
|
|
5a5e310a83 | ||
|
|
cb56a38680 | ||
|
|
750c7068e2 | ||
|
|
44e5b391f9 | ||
|
|
c86f1bbbe5 | ||
|
|
afe19f2105 | ||
|
|
73ee48e7c9 | ||
|
|
7727745b73 | ||
|
|
ad553b1082 | ||
|
|
429084e0bf | ||
|
|
79fbfec61f |
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Faxes',
|
'name': 'Fusion Faxes',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.2.1.1',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -32,5 +32,13 @@
|
|||||||
<field name="value"></field>
|
<field name="value"></field>
|
||||||
</record>
|
</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>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -17,12 +17,26 @@ class AccountMove(models.Model):
|
|||||||
string='Fax Count',
|
string='Fax Count',
|
||||||
compute='_compute_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')
|
@api.depends('x_ff_fax_ids')
|
||||||
def _compute_fax_count(self):
|
def _compute_fax_count(self):
|
||||||
for move in self:
|
for move in self:
|
||||||
move.x_ff_fax_count = len(move.x_ff_fax_ids)
|
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):
|
def action_send_fax(self):
|
||||||
"""Open the Send Fax wizard pre-filled with this invoice."""
|
"""Open the Send Fax wizard pre-filled with this invoice."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
string='Enable RingCentral Faxing',
|
string='Enable RingCentral Faxing',
|
||||||
config_parameter='fusion_faxes.ringcentral_enabled',
|
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(
|
ff_ringcentral_server_url = fields.Char(
|
||||||
string='RingCentral Server URL',
|
string='RingCentral Server URL',
|
||||||
config_parameter='fusion_faxes.ringcentral_server_url',
|
config_parameter='fusion_faxes.ringcentral_server_url',
|
||||||
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def set_values(self):
|
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 = [
|
protected_keys = [
|
||||||
'fusion_faxes.ringcentral_client_id',
|
'fusion_faxes.ringcentral_client_id',
|
||||||
'fusion_faxes.ringcentral_client_secret',
|
'fusion_faxes.ringcentral_client_secret',
|
||||||
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
existing = ICP.get_param(key, '')
|
existing = ICP.get_param(key, '')
|
||||||
if existing:
|
if existing:
|
||||||
ICP.set_param(key, 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',
|
string='Fax Count',
|
||||||
compute='_compute_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')
|
@api.depends('x_ff_fax_ids')
|
||||||
def _compute_fax_count(self):
|
def _compute_fax_count(self):
|
||||||
for order in self:
|
for order in self:
|
||||||
order.x_ff_fax_count = len(order.x_ff_fax_ids)
|
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):
|
def action_send_fax(self):
|
||||||
"""Open the Send Fax wizard pre-filled with this sale order."""
|
"""Open the Send Fax wizard pre-filled with this sale order."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
|
|
||||||
<!-- Send Fax header button (fax users only) -->
|
<!-- Send Fax header button (fax users only) -->
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||||
<button name="action_send_fax" string="Send Fax"
|
<button name="action_send_fax" string="Send Fax"
|
||||||
type="object" class="btn-secondary"
|
type="object" class="btn-secondary"
|
||||||
icon="fa-fax"
|
icon="fa-fax"
|
||||||
|
invisible="not x_ff_show_send_fax_button"
|
||||||
groups="fusion_faxes.group_fax_user"/>
|
groups="fusion_faxes.group_fax_user"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Server URL -->
|
||||||
<div class="col-12 col-lg-6 o_setting_box"
|
<div class="col-12 col-lg-6 o_setting_box"
|
||||||
invisible="not ff_ringcentral_enabled">
|
invisible="not ff_ringcentral_enabled">
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
|
|
||||||
<!-- Send Fax header button (fax users only) -->
|
<!-- Send Fax header button (fax users only) -->
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
|
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||||
<button name="action_send_fax" string="Send Fax"
|
<button name="action_send_fax" string="Send Fax"
|
||||||
type="object" class="btn-secondary"
|
type="object" class="btn-secondary"
|
||||||
icon="fa-fax"
|
icon="fa-fax"
|
||||||
|
invisible="not x_ff_show_send_fax_button"
|
||||||
groups="fusion_faxes.group_fax_user"/>
|
groups="fusion_faxes.group_fax_user"/>
|
||||||
</xpath>
|
</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` |
|
| **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` |
|
| **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` |
|
| **`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` |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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` |
|
| **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 |
|
| **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` |
|
| **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 |
|
| **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 |
|
| **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',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.20.6.2',
|
'version': '19.0.20.8.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
|
|||||||
'progress (e.g. paperwork or QA review that runs alongside '
|
'progress (e.g. paperwork or QA review that runs alongside '
|
||||||
'production).',
|
'production).',
|
||||||
)
|
)
|
||||||
|
long_running = fields.Boolean(
|
||||||
|
string='Long-running step',
|
||||||
|
default=False,
|
||||||
|
help='When True, steps generated from this recipe node are exempt '
|
||||||
|
'from the shop-floor auto-pause cron. Use for 24h bakes, '
|
||||||
|
'multi-shift soaks, and similar legitimately-long operations '
|
||||||
|
'that would otherwise be auto-paused after the idle threshold '
|
||||||
|
'(ir.config_parameter fp.shopfloor.autopause_threshold_hours, '
|
||||||
|
'default 8h). See plan 2026-05-22-shopfloor-tablet-redesign.',
|
||||||
|
)
|
||||||
opt_in_out = fields.Selection(
|
opt_in_out = fields.Selection(
|
||||||
[
|
[
|
||||||
('disabled', 'Required'),
|
('disabled', 'Required'),
|
||||||
|
|||||||
@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
|
|||||||
# field via _inherit if/when the bake-oven coupling is needed.
|
# field via _inherit if/when the bake-oven coupling is needed.
|
||||||
active = fields.Boolean(default=True)
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
# Phase 4 tablet redesign — Manager At-Risk heatmap inputs.
|
||||||
|
# Non-stored (recomputed on every read by /fp/manager/at_risk; the
|
||||||
|
# endpoint caches the payload for 60s anyway so the cost is bounded).
|
||||||
|
bottleneck_score = fields.Float(
|
||||||
|
compute='_compute_bottleneck',
|
||||||
|
string='Bottleneck Score',
|
||||||
|
help='active_step_count * avg_wait_minutes (rolling 7-day). '
|
||||||
|
'Drives the Manager At-Risk heatmap — work centres with '
|
||||||
|
'high score have queue + wait pressure.',
|
||||||
|
)
|
||||||
|
avg_wait_minutes = fields.Float(
|
||||||
|
compute='_compute_bottleneck',
|
||||||
|
string='Avg Wait (min)',
|
||||||
|
help='Average minutes that steps at this work centre waited in '
|
||||||
|
'ready state before starting, over the last 7 days.',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_bottleneck(self):
|
||||||
|
from datetime import timedelta
|
||||||
|
Step = self.env['fp.job.step']
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
seven_days_ago = now - timedelta(days=7)
|
||||||
|
for wc in self:
|
||||||
|
active_n = Step.search_count([
|
||||||
|
('work_centre_id', '=', wc.id),
|
||||||
|
('state', 'in', ('ready', 'in_progress')),
|
||||||
|
])
|
||||||
|
# Avg wait: recent steps where date_started is set; approximate
|
||||||
|
# "ready since" as create_date when no explicit ready timestamp
|
||||||
|
# is recorded. Bounded set (last 7 days) keeps the search cheap.
|
||||||
|
recent = Step.search([
|
||||||
|
('work_centre_id', '=', wc.id),
|
||||||
|
('date_started', '>=', seven_days_ago),
|
||||||
|
('date_started', '!=', False),
|
||||||
|
])
|
||||||
|
waits = []
|
||||||
|
for s in recent:
|
||||||
|
if s.create_date and s.date_started:
|
||||||
|
waits.append(
|
||||||
|
(s.date_started - s.create_date).total_seconds() / 60.0
|
||||||
|
)
|
||||||
|
avg = (sum(waits) / len(waits)) if waits else 0.0
|
||||||
|
wc.avg_wait_minutes = avg
|
||||||
|
wc.bottleneck_score = active_n * avg
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
|
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -96,6 +96,11 @@
|
|||||||
<field name="parallel_start"
|
<field name="parallel_start"
|
||||||
invisible="node_type not in ('operation', 'step')"
|
invisible="node_type not in ('operation', 'step')"
|
||||||
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
|
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
|
||||||
|
<!-- Phase 2 tablet redesign — opt out of the
|
||||||
|
auto-pause cron for legitimately-long steps
|
||||||
|
(24h bakes, multi-shift soaks). -->
|
||||||
|
<field name="long_running"
|
||||||
|
invisible="node_type not in ('operation', 'step')"/>
|
||||||
<field name="requires_predecessor_done"
|
<field name="requires_predecessor_done"
|
||||||
invisible="node_type not in ('operation', 'step')"
|
invisible="node_type not in ('operation', 'step')"
|
||||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.7.8.0',
|
'version': '19.0.7.9.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
||||||
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||||
|
|||||||
|
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.21.5.5',
|
'version': '19.0.21.7.2',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -56,10 +56,12 @@ Provides:
|
|||||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||||
'views/fp_configurator_menu.xml',
|
'views/fp_configurator_menu.xml',
|
||||||
|
'views/fp_so_job_sort_views.xml',
|
||||||
'data/fp_sale_description_template_data.xml',
|
'data/fp_sale_description_template_data.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'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/scss/fp_3d_viewer.scss',
|
||||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
'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/xml/fp_part_process_composer.xml',
|
||||||
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
|
'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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from . import fp_part_catalog
|
|||||||
from . import fp_pricing_complexity_surcharge
|
from . import fp_pricing_complexity_surcharge
|
||||||
from . import fp_pricing_rule
|
from . import fp_pricing_rule
|
||||||
from . import fp_sale_description_template
|
from . import fp_sale_description_template
|
||||||
|
from . import fp_so_job_sort
|
||||||
from . import fp_quote_configurator
|
from . import fp_quote_configurator
|
||||||
from . import fp_serial
|
from . import fp_serial
|
||||||
from . import sale_order
|
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.',
|
'Net Terms strategies.',
|
||||||
)
|
)
|
||||||
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
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(
|
x_fc_delivery_method = fields.Selection(
|
||||||
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
||||||
('customer_pickup', 'Customer Pickup')],
|
('customer_pickup', 'Customer Pickup')],
|
||||||
@@ -88,6 +110,16 @@ class SaleOrder(models.Model):
|
|||||||
help="Customer's internal job number for cross-referencing.",
|
help="Customer's internal job number for cross-referencing.",
|
||||||
tracking=True,
|
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(
|
x_fc_planned_start_date = fields.Date(
|
||||||
string='Planned Start Date', tracking=True,
|
string='Planned Start Date', tracking=True,
|
||||||
)
|
)
|
||||||
@@ -129,6 +161,16 @@ class SaleOrder(models.Model):
|
|||||||
string='Deadline',
|
string='Deadline',
|
||||||
compute='_compute_deadline_countdown',
|
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(
|
x_fc_order_completion_date = fields.Date(
|
||||||
string='Order Completion Date',
|
string='Order Completion Date',
|
||||||
compute='_compute_order_completion_date',
|
compute='_compute_order_completion_date',
|
||||||
@@ -241,6 +283,157 @@ class SaleOrder(models.Model):
|
|||||||
compute='_compute_invoiced_amount',
|
compute='_compute_invoiced_amount',
|
||||||
currency_field='currency_id',
|
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')
|
@api.depends('name')
|
||||||
def _compute_wo_completion(self):
|
def _compute_wo_completion(self):
|
||||||
@@ -493,9 +686,11 @@ class SaleOrder(models.Model):
|
|||||||
def _compute_deadline_countdown(self):
|
def _compute_deadline_countdown(self):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
|
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.commitment_date:
|
if not rec.commitment_date:
|
||||||
rec.x_fc_deadline_countdown = False
|
rec.x_fc_deadline_countdown = False
|
||||||
|
rec.x_fc_deadline_urgency = False
|
||||||
continue
|
continue
|
||||||
target = rec.commitment_date
|
target = rec.commitment_date
|
||||||
if isinstance(target, datetime):
|
if isinstance(target, datetime):
|
||||||
@@ -506,12 +701,13 @@ class SaleOrder(models.Model):
|
|||||||
secs = int(delta.total_seconds())
|
secs = int(delta.total_seconds())
|
||||||
if secs == 0:
|
if secs == 0:
|
||||||
rec.x_fc_deadline_countdown = 'due now'
|
rec.x_fc_deadline_countdown = 'due now'
|
||||||
|
rec.x_fc_deadline_urgency = 'overdue'
|
||||||
continue
|
continue
|
||||||
past = secs < 0
|
past = secs < 0
|
||||||
secs = abs(secs)
|
abs_secs = abs(secs)
|
||||||
days = secs // 86400
|
days = abs_secs // 86400
|
||||||
hours = (secs % 86400) // 3600
|
hours = (abs_secs % 86400) // 3600
|
||||||
mins = (secs % 3600) // 60
|
mins = (abs_secs % 3600) // 60
|
||||||
bits = []
|
bits = []
|
||||||
if days:
|
if days:
|
||||||
bits.append('%dd' % days)
|
bits.append('%dd' % days)
|
||||||
@@ -523,6 +719,12 @@ class SaleOrder(models.Model):
|
|||||||
rec.x_fc_deadline_countdown = (
|
rec.x_fc_deadline_countdown = (
|
||||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
'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(
|
@api.depends(
|
||||||
'order_line.x_fc_effective_part_deadline',
|
'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_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_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_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"
|
<field name="commitment_date" string="Delivery Date"
|
||||||
readonly="state in ('cancel',)"/>
|
readonly="state in ('cancel',)"/>
|
||||||
</xpath>
|
</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">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="Plating" name="plating_tab">
|
<page string="Plating" name="plating_tab">
|
||||||
<!-- Multi-part summary: read-only list of every order line
|
<!-- Multi-part summary: read-only list of every order line
|
||||||
@@ -201,6 +210,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<field name="x_fc_is_blanket_order"/>
|
<field name="x_fc_is_blanket_order"/>
|
||||||
<field name="x_fc_block_partial_shipments"/>
|
<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>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
@@ -358,19 +377,29 @@
|
|||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
display="always"/>
|
display="always"/>
|
||||||
</header>
|
</header>
|
||||||
<field name="name"/>
|
<field name="name" optional="show"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id" optional="show"/>
|
||||||
<field name="x_fc_po_number"/>
|
<field name="x_fc_po_number" optional="show"/>
|
||||||
<field name="x_fc_customer_job_number" optional="show"/>
|
<field name="x_fc_customer_job_number" optional="show"/>
|
||||||
<field name="x_fc_internal_deadline" optional="show"/>
|
<field name="x_fc_internal_deadline" optional="show"/>
|
||||||
<field name="commitment_date" string="Customer 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_order_completion_date" string="Completion" optional="show"/>
|
||||||
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
<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_wo_completion" optional="show"/>
|
||||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||||
<field name="x_fc_part_catalog_id" optional="hide"/>
|
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
|
||||||
<field name="amount_total" sum="Total"/>
|
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"
|
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
@@ -380,12 +409,21 @@
|
|||||||
<field name="x_fc_margin_percent" optional="hide"
|
<field name="x_fc_margin_percent" optional="hide"
|
||||||
widget="percentage"/>
|
widget="percentage"/>
|
||||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
<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"
|
<field name="x_fc_receiving_status" widget="badge"
|
||||||
|
optional="hide"
|
||||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||||
|
decoration-info="x_fc_receiving_status == 'partial'"
|
||||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||||
<field name="x_fc_delivery_method" optional="hide"/>
|
<field name="x_fc_delivery_method" optional="hide"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
<field name="state" widget="badge"/>
|
<field name="state" widget="badge" optional="show"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -455,8 +493,8 @@
|
|||||||
<field name="model">sale.order</field>
|
<field name="model">sale.order</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Quotations" decoration-muted="state == 'cancel'">
|
<list string="Quotations" decoration-muted="state == 'cancel'">
|
||||||
<field name="name"/>
|
<field name="name" optional="show"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id" optional="show"/>
|
||||||
<field name="x_fc_part_numbers_summary" optional="show"/>
|
<field name="x_fc_part_numbers_summary" optional="show"/>
|
||||||
<field name="x_fc_po_number" optional="hide"/>
|
<field name="x_fc_po_number" optional="hide"/>
|
||||||
<field name="x_fc_customer_job_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="validity_date" string="Expires" optional="show"/>
|
||||||
<field name="x_fc_follow_up_date" optional="show"/>
|
<field name="x_fc_follow_up_date" optional="show"/>
|
||||||
<field name="x_fc_follow_up_user_id" 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"
|
<field name="x_fc_is_signed" widget="boolean_toggle"
|
||||||
string="Signed" optional="show"/>
|
string="Signed" optional="show"/>
|
||||||
<field name="x_fc_email_status" widget="badge"
|
<field name="x_fc_email_status" widget="badge"
|
||||||
|
optional="show"
|
||||||
decoration-info="x_fc_email_status == 'sent'"
|
decoration-info="x_fc_email_status == 'sent'"
|
||||||
decoration-warning="x_fc_email_status == 'opened'"
|
decoration-warning="x_fc_email_status == 'opened'"
|
||||||
decoration-success="x_fc_email_status == 'won'"/>
|
decoration-success="x_fc_email_status == 'won'"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
<field name="state" widget="badge"/>
|
<field name="state" widget="badge" optional="show"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@@ -572,7 +611,10 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</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">
|
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||||
<field name="name">Sale Orders</field>
|
<field name="name">Sale Orders</field>
|
||||||
<field name="res_model">sale.order</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. "
|
help="Customer's internal job number for cross-referencing. "
|
||||||
"Appears on work orders and invoices.",
|
"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 ----
|
# ---- Scheduling ----
|
||||||
planned_start_date = fields.Date(
|
planned_start_date = fields.Date(
|
||||||
@@ -86,6 +93,11 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
)
|
)
|
||||||
internal_deadline = fields.Date(string='Internal Deadline')
|
internal_deadline = fields.Date(string='Internal Deadline')
|
||||||
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
|
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) ----
|
# ---- Order flags (Phase B) ----
|
||||||
is_blanket_order = fields.Boolean(
|
is_blanket_order = fields.Boolean(
|
||||||
@@ -528,8 +540,11 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'x_fc_po_pending': self.po_pending,
|
'x_fc_po_pending': self.po_pending,
|
||||||
'x_fc_po_expected_date': self.po_expected_date or False,
|
'x_fc_po_expected_date': self.po_expected_date or False,
|
||||||
'x_fc_customer_job_number': self.customer_job_number 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_planned_start_date': self.planned_start_date,
|
||||||
'x_fc_internal_deadline': self.internal_deadline,
|
'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.
|
# commitment_date is a Datetime; customer_deadline is a Date.
|
||||||
# Assigning a bare Date stores midnight UTC, which renders as
|
# Assigning a bare Date stores midnight UTC, which renders as
|
||||||
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts
|
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
options="{'no_create_edit': True}"
|
options="{'no_create_edit': True}"
|
||||||
invisible="not partner_id"/>
|
invisible="not partner_id"/>
|
||||||
<field name="customer_job_number"/>
|
<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>
|
||||||
<group string="Purchase Order">
|
<group string="Purchase Order">
|
||||||
<field name="po_number"
|
<field name="po_number"
|
||||||
@@ -102,6 +105,14 @@
|
|||||||
still `customer_deadline` (wizard) →
|
still `customer_deadline` (wizard) →
|
||||||
`commitment_date` (SO). -->
|
`commitment_date` (SO). -->
|
||||||
<field name="customer_deadline" string="Delivery Date"/>
|
<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="is_blanket_order"/>
|
||||||
<field name="block_partial_shipments"/>
|
<field name="block_partial_shipments"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.10.16.9',
|
'version': '19.0.10.20.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
@@ -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).
|
# so the statusbar's m2o has its targets available at view-render time).
|
||||||
'data/fp_workflow_state_data.xml',
|
'data/fp_workflow_state_data.xml',
|
||||||
'views/fp_workflow_state_views.xml',
|
'views/fp_workflow_state_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
|
||||||
'views/fp_job_step_quick_look_views.xml',
|
'views/fp_job_step_quick_look_views.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
'views/fp_job_quality_buttons.xml',
|
'views/fp_job_quality_buttons.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
|
'views/fp_receiving_views.xml',
|
||||||
'views/fp_certificate_views.xml',
|
'views/fp_certificate_views.xml',
|
||||||
'views/fp_job_consumption_views.xml',
|
'views/fp_job_consumption_views.xml',
|
||||||
'views/fp_step_priority_views.xml',
|
'views/fp_step_priority_views.xml',
|
||||||
|
|||||||
@@ -31,4 +31,21 @@
|
|||||||
<field name="interval_type">hours</field>
|
<field name="interval_type">hours</field>
|
||||||
<field name="active" eval="True"/>
|
<field name="active" eval="True"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Phase 2 tablet redesign — actual auto-pause (not just nudge).
|
||||||
|
Flips in_progress steps idle > N hours to paused with chatter
|
||||||
|
audit. Threshold configurable via ir.config_parameter
|
||||||
|
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe
|
||||||
|
nodes can opt out via long_running=True (e.g. 24h bakes). -->
|
||||||
|
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
|
||||||
|
<field name="name">Fusion Plating: Auto-pause stale in-progress steps</field>
|
||||||
|
<field name="model_id" ref="fusion_plating.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="numbercall">-1</field>
|
||||||
|
<field name="doall" eval="False"/>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from . import fp_certificate
|
|||||||
from . import fp_thickness_reading
|
from . import fp_thickness_reading
|
||||||
from . import fp_delivery
|
from . import fp_delivery
|
||||||
from . import fp_racking_inspection
|
from . import fp_racking_inspection
|
||||||
|
from . import fp_receiving
|
||||||
|
|
||||||
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
||||||
from . import fp_notification_trigger
|
from . import fp_notification_trigger
|
||||||
|
|||||||
@@ -137,10 +137,13 @@ class AccountMove(models.Model):
|
|||||||
if not job or not job.portal_job_id:
|
if not job or not job.portal_job_id:
|
||||||
return
|
return
|
||||||
portal = job.portal_job_id
|
portal = job.portal_job_id
|
||||||
if 'state' in portal._fields:
|
|
||||||
portal.state = 'complete'
|
|
||||||
if 'invoice_ref' in portal._fields:
|
if 'invoice_ref' in portal._fields:
|
||||||
portal.invoice_ref = self.name
|
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(
|
_logger.info(
|
||||||
'Invoice %s linked to fp.job %s portal %s',
|
'Invoice %s linked to fp.job %s portal %s',
|
||||||
self.name, job.name, portal.name,
|
self.name, job.name, portal.name,
|
||||||
|
|||||||
@@ -97,6 +97,81 @@ class FpJob(models.Model):
|
|||||||
'idempotency. Cleared post-cutover.',
|
'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}'
|
||||||
|
|
||||||
|
# Phase 2 — At-Risk view + Workspace landing focus.
|
||||||
|
late_risk_ratio = fields.Float(
|
||||||
|
compute='_compute_late_risk_ratio',
|
||||||
|
store=True,
|
||||||
|
string='Late-risk Ratio',
|
||||||
|
help='remaining_planned_minutes / minutes_to_deadline. '
|
||||||
|
'>1.0 means the job will be late if nothing changes. '
|
||||||
|
'Drives the At-Risk view on the manager dashboard.',
|
||||||
|
)
|
||||||
|
active_step_id = fields.Many2one(
|
||||||
|
'fp.job.step',
|
||||||
|
compute='_compute_active_step_id',
|
||||||
|
string='Active Step',
|
||||||
|
help='Currently in-progress step (lowest sequence if multiple — '
|
||||||
|
'defensive). Drives JobWorkspace landing focus.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'date_deadline',
|
||||||
|
'step_ids.state',
|
||||||
|
'step_ids.duration_expected',
|
||||||
|
)
|
||||||
|
def _compute_late_risk_ratio(self):
|
||||||
|
from datetime import datetime
|
||||||
|
for job in self:
|
||||||
|
if not job.date_deadline:
|
||||||
|
job.late_risk_ratio = 0.0
|
||||||
|
continue
|
||||||
|
open_steps = job.step_ids.filtered(
|
||||||
|
lambda s: s.state not in ('done', 'skipped', 'cancelled')
|
||||||
|
)
|
||||||
|
remaining_planned = sum(open_steps.mapped('duration_expected') or [0])
|
||||||
|
if remaining_planned <= 0:
|
||||||
|
job.late_risk_ratio = 0.0
|
||||||
|
continue
|
||||||
|
now = datetime.now()
|
||||||
|
# date_deadline is naive UTC in Odoo; compare directly
|
||||||
|
minutes_to_deadline = max(
|
||||||
|
1.0,
|
||||||
|
(job.date_deadline - now).total_seconds() / 60.0,
|
||||||
|
)
|
||||||
|
job.late_risk_ratio = remaining_planned / minutes_to_deadline
|
||||||
|
|
||||||
|
@api.depends('step_ids.state', 'step_ids.sequence')
|
||||||
|
def _compute_active_step_id(self):
|
||||||
|
for job in self:
|
||||||
|
active = job.step_ids.filtered(
|
||||||
|
lambda s: s.state == 'in_progress'
|
||||||
|
).sorted('sequence')
|
||||||
|
job.active_step_id = active[:1].id if active else False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Sub 14 — Configurable workflow state (status bar milestone)
|
# Sub 14 — Configurable workflow state (status bar milestone)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -552,6 +627,22 @@ class FpJob(models.Model):
|
|||||||
'context': {'default_job_id': self.id},
|
'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):
|
def action_finish_current_step(self):
|
||||||
"""Steelhead-style header button: finish whatever's currently
|
"""Steelhead-style header button: finish whatever's currently
|
||||||
in_progress and auto-start the next pending/ready step. If
|
in_progress and auto-start the next pending/ready step. If
|
||||||
@@ -745,16 +836,10 @@ class FpJob(models.Model):
|
|||||||
'name': self.portal_job_id.name,
|
'name': self.portal_job_id.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
|
# Sub-portal state sync — see fusion_plating_portal/.../fp_portal_job.py
|
||||||
# the customer doesn't see internal states. Anything not in this map
|
# `_fp_recompute_portal_state` for the rules. The mapping table that
|
||||||
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
|
# used to live here was replaced by the helper so shipment / invoice
|
||||||
_FP_JOB_STATE_TO_PORTAL_STATE = {
|
# signals can't drift away from the WO state any more.
|
||||||
'confirmed': 'received',
|
|
||||||
'in_progress': 'in_progress',
|
|
||||||
'done': 'ready_to_ship',
|
|
||||||
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
|
|
||||||
# what to surface to the customer.
|
|
||||||
}
|
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
||||||
@@ -783,13 +868,13 @@ class FpJob(models.Model):
|
|||||||
if job.state != new_state:
|
if job.state != new_state:
|
||||||
state_changed_ids.add(job.id)
|
state_changed_ids.add(job.id)
|
||||||
result = super().write(vals)
|
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:
|
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):
|
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:
|
if job.portal_job_id:
|
||||||
job.portal_job_id.sudo().write({'state': target})
|
job.portal_job_id._fp_recompute_portal_state()
|
||||||
if not scrap_deltas:
|
if not scrap_deltas:
|
||||||
return result
|
return result
|
||||||
Hold = (self.env['fusion.plating.quality.hold']
|
Hold = (self.env['fusion.plating.quality.hold']
|
||||||
|
|||||||
@@ -85,6 +85,128 @@ class FpJobStep(models.Model):
|
|||||||
)
|
)
|
||||||
step.can_start = not bool(blocking)
|
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
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# Shop-Floor auto-pause cron (Phase 2 — tablet redesign)
|
||||||
|
# ==================================================================
|
||||||
|
@api.model
|
||||||
|
def _cron_autopause_stale_steps(self):
|
||||||
|
"""Flip in_progress steps idle > threshold to paused.
|
||||||
|
|
||||||
|
Threshold read from ir.config_parameter
|
||||||
|
fp.shopfloor.autopause_threshold_hours (default 8.0)
|
||||||
|
|
||||||
|
Recipes can opt out per node via
|
||||||
|
fusion.plating.process.node.long_running (Phase 2 — P2.1)
|
||||||
|
|
||||||
|
Fixes the 411-hour ghost timer that bit us on the original tablet
|
||||||
|
when an operator started a step and never tapped Finish. Posts an
|
||||||
|
audit chatter entry on the step so the operator can see what
|
||||||
|
happened when they resume.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
threshold = float(
|
||||||
|
self.env['ir.config_parameter'].sudo()
|
||||||
|
.get_param('fp.shopfloor.autopause_threshold_hours', 8)
|
||||||
|
)
|
||||||
|
deadline = fields.Datetime.now() - timedelta(hours=threshold)
|
||||||
|
domain = [
|
||||||
|
('state', '=', 'in_progress'),
|
||||||
|
('date_started', '<', deadline),
|
||||||
|
'|',
|
||||||
|
('recipe_node_id', '=', False),
|
||||||
|
('recipe_node_id.long_running', '=', False),
|
||||||
|
]
|
||||||
|
stale = self.search(domain)
|
||||||
|
paused = 0
|
||||||
|
for step in stale:
|
||||||
|
try:
|
||||||
|
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 (%s) after %.1fh idle",
|
||||||
|
step.id, step.name, threshold,
|
||||||
|
)
|
||||||
|
paused += 1
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"Auto-pause failed for step %s — skipping", step.id,
|
||||||
|
)
|
||||||
|
if paused:
|
||||||
|
_logger.info(
|
||||||
|
"_cron_autopause_stale_steps: paused %d step(s) "
|
||||||
|
"(threshold %.1fh)", paused, threshold,
|
||||||
|
)
|
||||||
|
return paused
|
||||||
|
|
||||||
# NOTE: the actual button_start override lives further down (~line
|
# NOTE: the actual button_start override lives further down (~line
|
||||||
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||||
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
# 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',
|
string='Work Orders',
|
||||||
compute='_compute_fp_job_count',
|
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(
|
x_fc_fp_certificate_count = fields.Integer(
|
||||||
string='Certificates',
|
string='Certificates',
|
||||||
compute='_compute_fp_certificate_count',
|
compute='_compute_fp_certificate_count',
|
||||||
@@ -114,6 +121,25 @@ class SaleOrder(models.Model):
|
|||||||
[('sale_order_id', '=', so.id)]
|
[('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):
|
def _compute_fp_certificate_count(self):
|
||||||
Cert = self.env['fp.certificate'].sudo()
|
Cert = self.env['fp.certificate'].sudo()
|
||||||
for so in self:
|
for so in self:
|
||||||
|
|||||||
@@ -2,3 +2,8 @@
|
|||||||
from . import test_fp_job_extensions
|
from . import test_fp_job_extensions
|
||||||
from . import test_fp_job_milestone_cascade
|
from . import test_fp_job_milestone_cascade
|
||||||
from . import test_qty_received_propagation
|
from . import test_qty_received_propagation
|
||||||
|
from . import test_display_wo_name
|
||||||
|
from . import test_blocker_compute
|
||||||
|
from . import test_late_risk_ratio
|
||||||
|
from . import test_active_step_id
|
||||||
|
from . import test_autopause_cron
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||||
|
"""Plan task P2.3 — fp.job.active_step_id compute."""
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||||
|
class TestActiveStepId(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'AS'})
|
||||||
|
self.product = self.env['product.product'].create({'name': 'AS'})
|
||||||
|
self.job = self.env['fp.job'].create({
|
||||||
|
'name': 'WH/JOB/AS',
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'product_id': self.product.id,
|
||||||
|
'qty': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_no_active_step(self):
|
||||||
|
self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'S1',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'ready',
|
||||||
|
})
|
||||||
|
self.job.invalidate_recordset(['active_step_id'])
|
||||||
|
self.assertFalse(self.job.active_step_id.id)
|
||||||
|
|
||||||
|
def test_single_in_progress_step(self):
|
||||||
|
s = self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'S1',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'in_progress',
|
||||||
|
})
|
||||||
|
self.job.invalidate_recordset(['active_step_id'])
|
||||||
|
self.assertEqual(self.job.active_step_id.id, s.id)
|
||||||
|
|
||||||
|
def test_multiple_in_progress_picks_lowest_sequence(self):
|
||||||
|
s1 = self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'S1',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'in_progress',
|
||||||
|
})
|
||||||
|
self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'S2',
|
||||||
|
'sequence': 20,
|
||||||
|
'state': 'in_progress',
|
||||||
|
})
|
||||||
|
self.job.invalidate_recordset(['active_step_id'])
|
||||||
|
self.assertEqual(self.job.active_step_id.id, s1.id)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||||
|
"""Plan task P2.4 — _cron_autopause_stale_steps method."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||||
|
class TestAutopauseCron(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'AP'})
|
||||||
|
self.product = self.env['product.product'].create({'name': 'AP'})
|
||||||
|
self.job = self.env['fp.job'].create({
|
||||||
|
'name': 'WH/JOB/AP',
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'product_id': self.product.id,
|
||||||
|
'qty': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_stale_step_flips_to_paused(self):
|
||||||
|
step = self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'Stale',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'in_progress',
|
||||||
|
'date_started': datetime.now() - timedelta(hours=10),
|
||||||
|
})
|
||||||
|
paused = self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||||
|
self.assertGreaterEqual(paused, 1)
|
||||||
|
step.invalidate_recordset(['state'])
|
||||||
|
self.assertEqual(step.state, 'paused')
|
||||||
|
|
||||||
|
def test_fresh_step_unchanged(self):
|
||||||
|
step = self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'Fresh',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'in_progress',
|
||||||
|
'date_started': datetime.now() - timedelta(hours=2),
|
||||||
|
})
|
||||||
|
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||||
|
step.invalidate_recordset(['state'])
|
||||||
|
self.assertEqual(step.state, 'in_progress')
|
||||||
|
|
||||||
|
def test_long_running_node_exempt(self):
|
||||||
|
node = self.env['fusion.plating.process.node'].create({
|
||||||
|
'name': 'Long bake',
|
||||||
|
'long_running': True,
|
||||||
|
'node_type': 'operation',
|
||||||
|
})
|
||||||
|
step = self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'Long',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'in_progress',
|
||||||
|
'date_started': datetime.now() - timedelta(hours=20),
|
||||||
|
'recipe_node_id': node.id,
|
||||||
|
})
|
||||||
|
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||||
|
step.invalidate_recordset(['state'])
|
||||||
|
self.assertEqual(step.state, 'in_progress')
|
||||||
|
|
||||||
|
def test_threshold_config_parameter_respected(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
'fp.shopfloor.autopause_threshold_hours', '24',
|
||||||
|
)
|
||||||
|
step = self.env['fp.job.step'].create({
|
||||||
|
'job_id': self.job.id,
|
||||||
|
'name': 'Within 24h',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'in_progress',
|
||||||
|
'date_started': datetime.now() - timedelta(hours=10),
|
||||||
|
})
|
||||||
|
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||||
|
step.invalidate_recordset(['state'])
|
||||||
|
# 10h < 24h → still in_progress
|
||||||
|
self.assertEqual(step.state, 'in_progress')
|
||||||
@@ -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, '')
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||||
|
"""Plan task P2.2 — fp.job.late_risk_ratio compute."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||||
|
class TestLateRiskRatio(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'LR'})
|
||||||
|
self.product = self.env['product.product'].create({'name': 'LR'})
|
||||||
|
|
||||||
|
def _make_job(self, deadline=None):
|
||||||
|
return self.env['fp.job'].create({
|
||||||
|
'name': 'WH/JOB/LR',
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'product_id': self.product.id,
|
||||||
|
'qty': 1,
|
||||||
|
'date_deadline': deadline,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_no_deadline_zero(self):
|
||||||
|
job = self._make_job(deadline=False)
|
||||||
|
self.assertEqual(job.late_risk_ratio, 0.0)
|
||||||
|
|
||||||
|
def test_no_open_steps_zero(self):
|
||||||
|
job = self._make_job(deadline=datetime.now() + timedelta(hours=8))
|
||||||
|
self.assertEqual(job.late_risk_ratio, 0.0)
|
||||||
|
|
||||||
|
def test_ratio_above_one_when_overrun(self):
|
||||||
|
job = self._make_job(deadline=datetime.now() + timedelta(hours=2))
|
||||||
|
# One step planned for 240 min, only 120 min left → ratio ~ 2.0
|
||||||
|
self.env['fp.job.step'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'name': 'Long step',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'ready',
|
||||||
|
'duration_expected': 240,
|
||||||
|
})
|
||||||
|
job.invalidate_recordset(['late_risk_ratio'])
|
||||||
|
self.assertGreaterEqual(job.late_risk_ratio, 1.5)
|
||||||
|
|
||||||
|
def test_done_steps_dont_count_toward_remaining(self):
|
||||||
|
job = self._make_job(deadline=datetime.now() + timedelta(hours=4))
|
||||||
|
self.env['fp.job.step'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'name': 'Done',
|
||||||
|
'sequence': 10,
|
||||||
|
'state': 'done',
|
||||||
|
'duration_expected': 999,
|
||||||
|
})
|
||||||
|
self.env['fp.job.step'].create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'name': 'Tiny remaining',
|
||||||
|
'sequence': 20,
|
||||||
|
'state': 'ready',
|
||||||
|
'duration_expected': 30,
|
||||||
|
})
|
||||||
|
job.invalidate_recordset(['late_risk_ratio'])
|
||||||
|
# 30 min remaining vs 240 min to deadline → ratio ~ 0.125
|
||||||
|
self.assertLess(job.late_risk_ratio, 0.3)
|
||||||
@@ -20,6 +20,15 @@
|
|||||||
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
|
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//header" position="inside">
|
<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"
|
<button name="action_open_process_tree" type="object"
|
||||||
string="Process Tree"
|
string="Process Tree"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -63,15 +72,10 @@
|
|||||||
<field name="all_steps_terminal" invisible="1"/>
|
<field name="all_steps_terminal" invisible="1"/>
|
||||||
<field name="next_milestone_action" invisible="1"/>
|
<field name="next_milestone_action" invisible="1"/>
|
||||||
<button name="action_print_sticker" type="object"
|
<button name="action_print_sticker" type="object"
|
||||||
string="Print Sticker"
|
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-tag"
|
icon="fa-qrcode"
|
||||||
invisible="state == 'draft'"/>
|
invisible="state == 'draft'"
|
||||||
<button name="action_print_wo_detail" type="object"
|
help="Print Sticker"/>
|
||||||
string="Print WO Detail"
|
|
||||||
class="btn-secondary"
|
|
||||||
icon="fa-file-text-o"
|
|
||||||
invisible="state in ('draft', 'cancelled')"/>
|
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done
|
<!-- 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>
|
</button>
|
||||||
</xpath>
|
</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"
|
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
||||||
line under the SO name (the big SO-30000 heading). Only
|
line under the SO name (the big SO-30000 heading). Only
|
||||||
renders once the SO has been confirmed (quote_ref is set
|
renders once the SO has been confirmed (quote_ref is set
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Customer Portal',
|
'name': 'Fusion Plating — Customer Portal',
|
||||||
'version': '19.0.4.3.0',
|
'version': '19.0.4.4.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||||
'CoC downloads, invoice access.',
|
'CoC downloads, invoice access.',
|
||||||
|
|||||||
@@ -263,6 +263,83 @@ class FpPortalJob(models.Model):
|
|||||||
walk(mo.x_fc_recipe_id, 0)
|
walk(mo.x_fc_recipe_id, 0)
|
||||||
return result
|
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
|
# Per-stage timestamp snapshots
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Receiving & Inspection',
|
'name': 'Fusion Plating — Receiving & Inspection',
|
||||||
'version': '19.0.3.25.0',
|
'version': '19.0.3.27.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -160,14 +160,14 @@ class FusionShipment(models.Model):
|
|||||||
vals['packing_list_attachment_id'] = (
|
vals['packing_list_attachment_id'] = (
|
||||||
delivery.packing_list_attachment_id.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:
|
if vals:
|
||||||
portal.sudo().write(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(
|
x_fc_receiving_count = fields.Integer(
|
||||||
string='Receiving Count', compute='_compute_receiving_count',
|
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')
|
@api.depends('x_fc_receiving_ids')
|
||||||
def _compute_receiving_count(self):
|
def _compute_receiving_count(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
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):
|
def action_confirm(self):
|
||||||
"""Override to auto-create receiving record on SO confirmation.
|
"""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;
|
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"/>
|
<field name="x_fc_receiving_count" widget="statinfo" string="Receiving"/>
|
||||||
</button>
|
</button>
|
||||||
</xpath>
|
</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>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.11.26.14',
|
'version': '19.0.11.26.30',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -90,7 +90,12 @@
|
|||||||
<t t-set="_desc"
|
<t t-set="_desc"
|
||||||
t-value="line.fp_customer_description() if _has_helper else line.name"/>
|
t-value="line.fp_customer_description() if _has_helper else line.name"/>
|
||||||
<span t-esc="_desc" style="white-space: pre-line;"/>
|
<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/>
|
<br/>
|
||||||
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -73,6 +73,26 @@
|
|||||||
<field name="dpi">90</field>
|
<field name="dpi">90</field>
|
||||||
</record>
|
</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 -->
|
<!-- 1. Certificate of Conformance (Portal Job) — Landscape -->
|
||||||
<!-- ============================================================= -->
|
<!-- ============================================================= -->
|
||||||
@@ -394,9 +414,10 @@
|
|||||||
<field name="report_type">qweb-pdf</field>
|
<field name="report_type">qweb-pdf</field>
|
||||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_portrait</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="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_model_id" ref="stock.model_stock_picking"/>
|
||||||
<field name="binding_type">report</field>
|
<field name="binding_type">report</field>
|
||||||
|
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="action_report_fp_packing_slip_landscape" model="ir.actions.report">
|
<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_type">qweb-pdf</field>
|
||||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_landscape</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="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_model_id" ref="stock.model_stock_picking"/>
|
||||||
<field name="binding_type">report</field>
|
<field name="binding_type">report</field>
|
||||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
<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_model_id" ref="account.model_account_move"/>
|
||||||
<field name="binding_type">report</field>
|
<field name="binding_type">report</field>
|
||||||
<field name="is_invoice_report" eval="True"/>
|
<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>
|
||||||
|
|
||||||
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
|
<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="print_report_name">'Invoice - %s' % (object.name or '')</field>
|
||||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||||
<field name="binding_type">report</field>
|
<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>
|
</record>
|
||||||
|
|
||||||
<!-- ============================================================= -->
|
<!-- ============================================================= -->
|
||||||
|
|||||||
@@ -14,26 +14,92 @@
|
|||||||
<template id="report_fp_invoice_portrait">
|
<template id="report_fp_invoice_portrait">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-foreach="docs" t-as="doc">
|
<t t-foreach="docs" t-as="doc">
|
||||||
<t t-call="web.external_layout">
|
<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="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_portrait_styles"/>
|
||||||
<div class="fp-report">
|
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<h4>
|
<!-- Compute helpers -->
|
||||||
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
|
<t t-set="title_en" t-value="
|
||||||
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
|
'Credit Note' if doc.move_type == 'out_refund'
|
||||||
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
|
else 'Vendor Bill' if doc.move_type == 'in_invoice'
|
||||||
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
|
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||||
<span t-field="doc.name"/>
|
else 'Invoice'"/>
|
||||||
</h4>
|
<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 -->
|
<!-- Billing / Shipping -->
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
<th style="width: 50%;">
|
||||||
<th style="width: 50%;">DELIVERY ADDRESS</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -56,53 +122,131 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Invoice info -->
|
<!-- Row 1: Invoice Date | Due Date | Sales Rep | Customer PO # | Payment Ref -->
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="info-header" style="width: 25%;">INVOICE DATE</th>
|
<th class="info-header" style="width: 20%;">
|
||||||
<th class="info-header" style="width: 25%;">DUE DATE</th>
|
<span class="fp-bl-en-stk">Invoice Date</span>
|
||||||
<th class="info-header" style="width: 25%;">SOURCE</th>
|
<span class="fp-bl-fr-stk">Date de facture</span>
|
||||||
<th class="info-header" style="width: 25%;">SALES REP</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><span t-field="doc.invoice_date"/></td>
|
<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_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-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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-start" style="width: 20%;">PART NUMBER</th>
|
<th class="info-header" style="width: 34%;">
|
||||||
<th class="text-start" style="width: 32%;">DESCRIPTION</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 style="width: 8%;">QTY</th>
|
</th>
|
||||||
<th style="width: 8%;">UOM</th>
|
<th class="info-header" style="width: 33%;">
|
||||||
<th style="width: 12%;">UNIT PRICE</th>
|
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
|
||||||
<th style="width: 8%;">TAXES</th>
|
</th>
|
||||||
<th style="width: 12%;">AMOUNT</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: 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<t t-foreach="doc.invoice_line_ids" t-as="line">
|
<t t-foreach="doc.invoice_line_ids" t-as="line">
|
||||||
<t t-if="line.display_type == 'line_section'">
|
<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 t-elif="line.display_type == 'line_note'">
|
<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 t-elif="not line.display_type or line.display_type == 'product'">
|
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
<!-- Three stacked lines: Part #, Name, S/N -->
|
||||||
|
<div>
|
||||||
|
<strong>Part #:</strong>
|
||||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
<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>
|
||||||
<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"/>
|
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -112,9 +256,6 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</td>
|
</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">
|
<td class="text-end">
|
||||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</td>
|
</td>
|
||||||
@@ -127,40 +268,47 @@
|
|||||||
<!-- Terms + Totals -->
|
<!-- Terms + Totals -->
|
||||||
<div class="row" style="margin-top: 15px;">
|
<div class="row" style="margin-top: 15px;">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
|
<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">
|
<t t-if="doc.invoice_payment_term_id.note">
|
||||||
<strong>Payment Terms:</strong><br/>
|
|
||||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="doc.payment_reference">
|
<t t-else="">
|
||||||
<div style="margin-top: 10px;">
|
<span t-field="doc.invoice_payment_term_id.name"/>
|
||||||
<strong>Payment Reference:</strong>
|
</t>
|
||||||
<span t-field="doc.payment_reference"/>
|
|
||||||
</div>
|
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6" style="text-align: right;">
|
<div class="col-6" style="text-align: right;">
|
||||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||||
<tr>
|
<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;">
|
<td class="text-end" style="min-width: 110px;">
|
||||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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">
|
<td class="text-end">
|
||||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="background-color: #c1c1c1;">
|
<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>
|
<td class="text-end"><strong>
|
||||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</strong></td>
|
</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
||||||
<tr>
|
<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>
|
<td class="text-end"><strong>
|
||||||
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</strong></td>
|
</strong></td>
|
||||||
@@ -173,14 +321,14 @@
|
|||||||
<!-- Paid stamp -->
|
<!-- Paid stamp -->
|
||||||
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
||||||
<div style="margin-top: 15px; text-align: center;">
|
<div style="margin-top: 15px; text-align: center;">
|
||||||
<span class="paid-stamp">PAID</span>
|
<span class="paid-stamp">PAID / PAYÉ</span>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<t t-if="doc.narration">
|
<t t-if="doc.narration">
|
||||||
<div style="margin-top: 15px;">
|
<div style="margin-top: 15px;">
|
||||||
<strong>Notes:</strong>
|
<strong>Notes / Remarques:</strong>
|
||||||
<div t-field="doc.narration"/>
|
<div t-field="doc.narration"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
@@ -198,26 +346,87 @@
|
|||||||
<template id="report_fp_invoice_landscape">
|
<template id="report_fp_invoice_landscape">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-foreach="docs" t-as="doc">
|
<t t-foreach="docs" t-as="doc">
|
||||||
<t t-call="web.external_layout">
|
<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="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_landscape_styles"/>
|
||||||
<div class="fp-landscape">
|
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<h2 style="text-align: left;">
|
<!-- Same compute helpers as portrait -->
|
||||||
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
|
<t t-set="title_en" t-value="
|
||||||
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
|
'Credit Note' if doc.move_type == 'out_refund'
|
||||||
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
|
else 'Vendor Bill' if doc.move_type == 'in_invoice'
|
||||||
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
|
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||||
<span t-field="doc.name"/>
|
else 'Invoice'"/>
|
||||||
</h2>
|
<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 -->
|
<!-- Billing / Shipping -->
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
<th style="width: 50%;">
|
||||||
<th style="width: 50%;">DELIVERY ADDRESS</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -240,44 +449,106 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Invoice info (wide) -->
|
<!-- Invoice info row (wide, 6 cols on landscape) -->
|
||||||
<table class="bordered info-table">
|
<table class="bordered info-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>INVOICE DATE</th>
|
<th>
|
||||||
<th>DUE DATE</th>
|
<span class="fp-bl-en-stk">Invoice Date</span>
|
||||||
<th>SOURCE</th>
|
<span class="fp-bl-fr-stk">Date de facture</span>
|
||||||
<th>SALES REP</th>
|
</th>
|
||||||
<th>PAYMENT REF</th>
|
<th>
|
||||||
<th>CURRENCY</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><span t-field="doc.invoice_date"/></td>
|
<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_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-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>
|
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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="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">
|
<table class="bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-start" style="width: 18%;">PART NUMBER</th>
|
<th class="text-start" style="width: 22%;">
|
||||||
<th class="text-start" style="width: 24%;">DESCRIPTION</th>
|
<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 style="width: 8%;">QTY</th>
|
</th>
|
||||||
<th style="width: 8%;">UOM</th>
|
<th class="text-start" style="width: 30%;">
|
||||||
<th style="width: 12%;">UNIT PRICE</th>
|
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
|
||||||
<th t-if="has_discount" style="width: 10%;">DISCOUNT</th>
|
</th>
|
||||||
<th style="width: 10%;">TAXES</th>
|
<th style="width: 7%;">
|
||||||
<th style="width: 10%;">AMOUNT</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -291,9 +562,28 @@
|
|||||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
<!-- Three stacked lines: Part #, Name, S/N -->
|
||||||
|
<div>
|
||||||
|
<strong>Part #:</strong>
|
||||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
<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>
|
||||||
<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"/>
|
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@@ -305,10 +595,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td t-if="has_discount" class="text-center">
|
<td t-if="has_discount" class="text-center">
|
||||||
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
|
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
|
||||||
<t t-else="">-</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 '-'"/>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
@@ -322,34 +609,47 @@
|
|||||||
<!-- Terms + Totals -->
|
<!-- Terms + Totals -->
|
||||||
<div class="row" style="margin-top: 15px;">
|
<div class="row" style="margin-top: 15px;">
|
||||||
<div class="col-7">
|
<div class="col-7">
|
||||||
|
<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">
|
<t t-if="doc.invoice_payment_term_id.note">
|
||||||
<strong>Payment Terms:</strong><br/>
|
|
||||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||||
</t>
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span t-field="doc.invoice_payment_term_id.name"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5" style="text-align: right;">
|
<div class="col-5" style="text-align: right;">
|
||||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||||
<tr>
|
<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;">
|
<td class="text-end" style="min-width: 150px;">
|
||||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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">
|
<td class="text-end">
|
||||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="background-color: #c1c1c1;">
|
<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>
|
<td class="text-end"><strong>
|
||||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</strong></td>
|
</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
||||||
<tr>
|
<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>
|
<td class="text-end"><strong>
|
||||||
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||||
</strong></td>
|
</strong></td>
|
||||||
@@ -362,7 +662,7 @@
|
|||||||
<!-- Paid stamp -->
|
<!-- Paid stamp -->
|
||||||
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
||||||
<div style="margin-top: 15px; text-align: center;">
|
<div style="margin-top: 15px; text-align: center;">
|
||||||
<span class="paid-stamp">PAID</span>
|
<span class="paid-stamp">PAID / PAYÉ</span>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,25 @@
|
|||||||
<!-- ============================================================= -->
|
<!-- ============================================================= -->
|
||||||
<template id="fp_packing_slip_styles">
|
<template id="fp_packing_slip_styles">
|
||||||
<style>
|
<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 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-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; }
|
.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_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="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 '')"/>
|
<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_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"/>
|
<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">
|
<div class="page">
|
||||||
|
|
||||||
<h4>
|
<!-- Bilingual title (EN bold on top, FR
|
||||||
Packing Slip #
|
italic-grey below) matching SO and CoC. -->
|
||||||
<span t-field="doc.name"/>
|
<div class="fp-ps-titlebar">
|
||||||
</h4>
|
<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 -->
|
<!-- Bill To / Ship To -->
|
||||||
<table class="bordered fp-ps-addrtable">
|
<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_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="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 '')"/>
|
<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_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"/>
|
<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">
|
<div class="page">
|
||||||
|
|
||||||
<h2 style="text-align: left;">
|
<div class="fp-ps-titlebar">
|
||||||
Packing Slip #
|
<t t-if="ps_barcode_uri">
|
||||||
<span t-field="doc.name"/>
|
<div class="fp-ps-barcode">
|
||||||
</h2>
|
<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 -->
|
<!-- Bill To / Ship To -->
|
||||||
<table class="bordered fp-ps-addrtable">
|
<table class="bordered fp-ps-addrtable">
|
||||||
|
|||||||
@@ -22,6 +22,46 @@
|
|||||||
External_layout already places the page body at the bottom of
|
External_layout already places the page body at the bottom of
|
||||||
the reserved margin-top — don't fight that. Use a small positive
|
the reserved margin-top — don't fight that. Use a small positive
|
||||||
gap and shrink the title text instead. -->
|
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">
|
<template id="fp_sale_bilingual_styles">
|
||||||
<style>
|
<style>
|
||||||
/* Inline bilingual: English bold, then a faint slash, then
|
/* Inline bilingual: English bold, then a faint slash, then
|
||||||
@@ -38,13 +78,58 @@
|
|||||||
below in italic-grey. */
|
below in italic-grey. */
|
||||||
.fp-bl-en-stk { display: block; font-weight: bold; }
|
.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; }
|
.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
|
/* This template uses fp_external_layout_clean (sibling
|
||||||
(8mm) lets the header HTML overflow into the body area,
|
template in this file) instead of web.external_layout.
|
||||||
and this 20mm padding-top on the wrapper clears it. See
|
That gives us the `.article` wrapper Odoo's report
|
||||||
CLAUDE.md "wkhtmltopdf header overlap" — the alternative
|
renderer needs for proper UTF-8 dispatch, WITHOUT the
|
||||||
"size margin_top to the header height" approach has zero
|
`.header` / `.footer` divs that wkhtmltopdf would
|
||||||
slack and breaks any time the header HTML grows. */
|
extract into page-margin streams. So the body owns the
|
||||||
.fp-report.fp-sale { padding-top: 20mm; }
|
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 —
|
/* Title bar uses float-based div layout, NOT an HTML table —
|
||||||
the global ".fp-report table" rule was applying borders
|
the global ".fp-report table" rule was applying borders
|
||||||
to every nested table even with "border: 0 !important",
|
to every nested table even with "border: 0 !important",
|
||||||
@@ -65,15 +150,19 @@
|
|||||||
.fp-sale-barcode { float: right; margin-left: 12px; }
|
.fp-sale-barcode { float: right; margin-left: 12px; }
|
||||||
.fp-bc-wrap { display: inline-block; text-align: center; }
|
.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 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>
|
</style>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="report_fp_sale_portrait">
|
<template id="report_fp_sale_portrait">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-foreach="docs" t-as="doc">
|
<t t-foreach="docs" t-as="doc">
|
||||||
<t t-call="web.external_layout">
|
<!-- 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="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_portrait_styles"/>
|
||||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||||
|
|
||||||
@@ -84,29 +173,60 @@
|
|||||||
<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="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="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="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="fp-report fp-sale">
|
||||||
<div class="page">
|
<!-- Inline header (drops web.external_layout for this
|
||||||
|
report — see CSS comment for context). Left: logo
|
||||||
<!-- Title bar: stacked English/French title
|
+ address + tel/fax + URL. Right: bilingual title
|
||||||
on the left, Code128 barcode floated
|
+ Code128 barcode of the order number. -->
|
||||||
right. NO HTML table — see CLAUDE.md
|
<div class="fp-sale-header-row">
|
||||||
"wkhtmltopdf header overlap" §2 for why
|
<div class="fp-sale-header-left">
|
||||||
a table here leaks borders. -->
|
<t t-if="logo_uri">
|
||||||
<div class="fp-sale-titlebar">
|
<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">
|
<t t-if="barcode_uri">
|
||||||
<div class="fp-sale-barcode">
|
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||||
<div class="fp-bc-wrap">
|
|
||||||
<img t-att-src="barcode_uri" alt="Order Barcode"/>
|
<img t-att-src="barcode_uri" alt="Order Barcode"/>
|
||||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</t>
|
</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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
<!-- Billing / Shipping (wide cells — inline) -->
|
<!-- Billing / Shipping (wide cells — inline) -->
|
||||||
<table class="bordered">
|
<table class="bordered">
|
||||||
@@ -174,10 +294,18 @@
|
|||||||
<td class="text-center"><span t-field="doc.user_id"/></td>
|
<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"><span t-esc="doc.x_fc_po_number or '—'"/></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<t t-if="doc.x_fc_rush_order">
|
<!-- Lead Time renders from the
|
||||||
<span class="status-warning">Rush / Urgent</span>
|
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 t-else="">Standard</t>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -267,13 +395,34 @@
|
|||||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
<!-- 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"/>
|
<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">
|
<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"/>
|
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||||
</t>
|
</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>
|
||||||
<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"/>
|
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.26.2.0',
|
'version': '19.0.29.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'first-piece inspection gates.',
|
||||||
@@ -62,6 +62,32 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
||||||
# so tokens are resolved via bundle concatenation order).
|
# so tokens are resolved via bundle concatenation order).
|
||||||
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
|
'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',
|
||||||
|
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
|
||||||
|
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
|
||||||
|
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
|
||||||
|
'fusion_plating_shopfloor/static/src/js/shopfloor_landing.js',
|
||||||
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
|
'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/fusion_plating_shopfloor.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ from . import shopfloor_controller
|
|||||||
from . import manager_controller
|
from . import manager_controller
|
||||||
from . import tank_status
|
from . import tank_status
|
||||||
from . import move_controller
|
from . import move_controller
|
||||||
|
from . import workspace_controller
|
||||||
|
from . import landing_controller
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# -*- 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 endpoint for the Shop Floor Landing kanban (Phase 3).
|
||||||
|
|
||||||
|
Replaces the data path for both fp_shopfloor_tablet (legacy) and
|
||||||
|
fp_plant_overview (legacy). Two modes:
|
||||||
|
|
||||||
|
station — paired station's work centre + Unassigned + next 1-2 WCs
|
||||||
|
in the recipe flow. The physical-station view.
|
||||||
|
all_plant — every active work centre, sorted by recipe flow.
|
||||||
|
|
||||||
|
The card payload shape matches the existing plant_overview cards so
|
||||||
|
the front-end can share the KanbanCard component. Tapping a card opens
|
||||||
|
the JobWorkspace via doAction (handled client-side).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
_ACTIVE_STEP_STATES = ('ready', 'in_progress', 'paused')
|
||||||
|
|
||||||
|
|
||||||
|
class FpLandingController(http.Controller):
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# /fp/landing/kanban
|
||||||
|
# ======================================================================
|
||||||
|
@http.route('/fp/landing/kanban', type='jsonrpc', auth='user')
|
||||||
|
def kanban(self, mode='all_plant', station_id=None, search=None):
|
||||||
|
env = request.env
|
||||||
|
Step = env['fp.job.step']
|
||||||
|
WorkCentre = env['fp.work.centre']
|
||||||
|
|
||||||
|
# ---- Resolve station / facility scope ----------------------------
|
||||||
|
station = None
|
||||||
|
facility = None
|
||||||
|
if station_id:
|
||||||
|
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
|
||||||
|
if stn.exists():
|
||||||
|
station = stn
|
||||||
|
facility = stn.facility_id
|
||||||
|
if not facility:
|
||||||
|
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||||
|
|
||||||
|
# ---- Which work centres to render --------------------------------
|
||||||
|
wc_dom = [('active', '=', True)]
|
||||||
|
if facility:
|
||||||
|
wc_dom.append(('facility_id', '=', facility.id))
|
||||||
|
all_wcs = WorkCentre.search(wc_dom, order='sequence, code, name')
|
||||||
|
|
||||||
|
if mode == 'station' and station and station.work_center_id:
|
||||||
|
this_wc = station.work_center_id
|
||||||
|
# Show this WC + next 1-2 WCs in the recipe flow (preview)
|
||||||
|
after = all_wcs.filtered(
|
||||||
|
lambda w: w.sequence > this_wc.sequence
|
||||||
|
)[:2]
|
||||||
|
relevant_wcs = this_wc | after
|
||||||
|
else:
|
||||||
|
relevant_wcs = all_wcs
|
||||||
|
|
||||||
|
# ---- Active steps in scope ---------------------------------------
|
||||||
|
step_dom = [('state', 'in', _ACTIVE_STEP_STATES)]
|
||||||
|
if facility:
|
||||||
|
step_dom.append(('work_centre_id.facility_id', '=', facility.id))
|
||||||
|
if mode == 'station' and relevant_wcs:
|
||||||
|
# In station mode, include the relevant WCs + Unassigned only.
|
||||||
|
# The OR-of-three-leaves is what makes this filter "this WC,
|
||||||
|
# the next 1-2 WCs, or Unassigned" — three branches OR'd.
|
||||||
|
step_dom = step_dom + [
|
||||||
|
'|', '|',
|
||||||
|
('work_centre_id', 'in', relevant_wcs.ids),
|
||||||
|
('work_centre_id', '=', False),
|
||||||
|
('work_centre_id', 'in', relevant_wcs.ids),
|
||||||
|
]
|
||||||
|
|
||||||
|
steps = Step.search(step_dom, order='sequence, id')
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_l = search.strip().lower()
|
||||||
|
steps = steps.filtered(lambda s: (
|
||||||
|
search_l in (s.job_id.display_wo_name or '').lower()
|
||||||
|
or search_l in (s.job_id.partner_id.name or '').lower()
|
||||||
|
or search_l in (
|
||||||
|
s.job_id.part_catalog_id.part_number or ''
|
||||||
|
if s.job_id.part_catalog_id else ''
|
||||||
|
).lower()
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---- Group into columns ------------------------------------------
|
||||||
|
cards_by_wc = {0: []} # 0 = Unassigned sentinel
|
||||||
|
for step in steps:
|
||||||
|
wc_id = step.work_centre_id.id or 0
|
||||||
|
cards_by_wc.setdefault(wc_id, []).append(self._step_to_card(step))
|
||||||
|
|
||||||
|
columns = []
|
||||||
|
for wc in relevant_wcs:
|
||||||
|
columns.append({
|
||||||
|
'work_center_id': wc.id,
|
||||||
|
'work_center_name': wc.name,
|
||||||
|
'cards': cards_by_wc.get(wc.id, []),
|
||||||
|
})
|
||||||
|
if cards_by_wc.get(0):
|
||||||
|
columns.append({
|
||||||
|
'work_center_id': 0,
|
||||||
|
'work_center_name': 'Unassigned',
|
||||||
|
'cards': cards_by_wc[0],
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- KPIs — 4 tech-relevant tiles --------------------------------
|
||||||
|
ready = sum(1 for s in steps if s.state == 'ready')
|
||||||
|
running = sum(1 for s in steps if s.state == 'in_progress')
|
||||||
|
|
||||||
|
BakeWindow = env['fusion.plating.bake.window']
|
||||||
|
bake_dom = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
|
||||||
|
if facility:
|
||||||
|
bake_dom.append(('facility_id', '=', facility.id))
|
||||||
|
bakes_due = BakeWindow.search_count(bake_dom)
|
||||||
|
|
||||||
|
Hold = env['fusion.plating.quality.hold']
|
||||||
|
holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
|
||||||
|
|
||||||
|
# ---- Station picker payload (so client can switch stations) ------
|
||||||
|
all_stations = env['fusion.plating.shopfloor.station'].search(
|
||||||
|
[], order='facility_id, name',
|
||||||
|
)
|
||||||
|
stations = [
|
||||||
|
{
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'code': s.code or '',
|
||||||
|
'facility': s.facility_id.name or '',
|
||||||
|
'work_center_name': s.work_center_id.name or '',
|
||||||
|
}
|
||||||
|
for s in all_stations
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'mode': mode,
|
||||||
|
'station': {
|
||||||
|
'id': station.id,
|
||||||
|
'name': station.name,
|
||||||
|
'code': station.code or '',
|
||||||
|
'work_center_name': station.work_center_id.name or '',
|
||||||
|
} if station else None,
|
||||||
|
'facility_name': facility.name if facility else '',
|
||||||
|
'columns': columns,
|
||||||
|
'kpis': {
|
||||||
|
'ready': ready,
|
||||||
|
'running': running,
|
||||||
|
'bakes_due': bakes_due,
|
||||||
|
'holds': holds,
|
||||||
|
},
|
||||||
|
'stations': stations,
|
||||||
|
'server_time': fp_format(env, fields.Datetime.now(), fmt='%H:%M:%S'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _step_to_card(self, step):
|
||||||
|
"""Build the kanban card payload for one fp.job.step.
|
||||||
|
|
||||||
|
Shape matches the KanbanCard OWL component (Phase 1 — P1.7).
|
||||||
|
"""
|
||||||
|
job = step.job_id
|
||||||
|
return {
|
||||||
|
'step_id': step.id,
|
||||||
|
'job_id': job.id,
|
||||||
|
'display_wo_name': job.display_wo_name,
|
||||||
|
'customer': job.partner_id.name or '',
|
||||||
|
'part': (
|
||||||
|
job.part_catalog_id.part_number
|
||||||
|
if 'part_catalog_id' in job._fields and job.part_catalog_id
|
||||||
|
else (job.product_id.display_name or '')
|
||||||
|
),
|
||||||
|
'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(
|
||||||
|
request.env, job.date_deadline, fmt='%b %d',
|
||||||
|
) if job.date_deadline else '',
|
||||||
|
'priority': job.priority or 'normal',
|
||||||
|
'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,
|
||||||
|
'blocker_kind': step.blocker_kind,
|
||||||
|
'blocker_reason': step.blocker_reason or '',
|
||||||
|
'current_step_id': step.id,
|
||||||
|
'current_step_name': step.name,
|
||||||
|
'work_center': step.work_centre_id.name or '',
|
||||||
|
}
|
||||||
@@ -466,3 +466,231 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
return {'ok': True, 'user_name': user.name}
|
return {'ok': True, 'user_name': user.name}
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
|
||||||
|
def funnel(self, facility_id=None):
|
||||||
|
"""Workflow funnel: jobs grouped by fp.job.workflow.state.
|
||||||
|
|
||||||
|
One row per workflow stage with its count + top 5 WO cards.
|
||||||
|
Drives the default tab on the refactored Manager Dashboard.
|
||||||
|
"""
|
||||||
|
env = request.env
|
||||||
|
Job = env['fp.job']
|
||||||
|
all_states = env['fp.job.workflow.state'].search(
|
||||||
|
[], order='sequence, id',
|
||||||
|
)
|
||||||
|
# All in-flight jobs (not done/cancelled)
|
||||||
|
job_dom = [('state', 'not in', _NEG_JOB_STATES)]
|
||||||
|
if facility_id:
|
||||||
|
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||||
|
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
|
||||||
|
|
||||||
|
# Group jobs by workflow_state_id (in-memory — list is bounded by
|
||||||
|
# active job count, typically < 200)
|
||||||
|
by_stage = {ws.id: [] for ws in all_states}
|
||||||
|
for job in jobs:
|
||||||
|
if job.workflow_state_id and job.workflow_state_id.id in by_stage:
|
||||||
|
by_stage[job.workflow_state_id.id].append(job)
|
||||||
|
|
||||||
|
def _job_card_compact(job):
|
||||||
|
return {
|
||||||
|
'job_id': job.id,
|
||||||
|
'display_wo_name': job.display_wo_name,
|
||||||
|
'customer': job.partner_id.name or '',
|
||||||
|
'priority': job.priority or 'normal',
|
||||||
|
'days_in_stage': (
|
||||||
|
(fields.Datetime.now() - job.write_date).days
|
||||||
|
if job.write_date else 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
stages = []
|
||||||
|
for ws in all_states:
|
||||||
|
jobs_in_stage = by_stage[ws.id]
|
||||||
|
stages.append({
|
||||||
|
'id': ws.id,
|
||||||
|
'name': ws.name,
|
||||||
|
'color': ws.color or 'grey',
|
||||||
|
'sequence': ws.sequence or 0,
|
||||||
|
'count': len(jobs_in_stage),
|
||||||
|
'jobs': [_job_card_compact(j) for j in jobs_in_stage[:5]],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {'ok': True, 'stages': stages}
|
||||||
|
|
||||||
|
@http.route('/fp/manager/approval_inbox', type='jsonrpc', auth='user')
|
||||||
|
def approval_inbox(self, facility_id=None):
|
||||||
|
"""Approval Inbox: things waiting on a manager decision.
|
||||||
|
|
||||||
|
Four buckets: holds to release, certs to issue, recent scrap to
|
||||||
|
acknowledge, override requests (deferred — empty for now).
|
||||||
|
"""
|
||||||
|
env = request.env
|
||||||
|
|
||||||
|
# ---- Holds to Release -------------------------------------------
|
||||||
|
Hold = env['fusion.plating.quality.hold']
|
||||||
|
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
|
||||||
|
holds = Hold.search(hold_dom, order='create_date desc', limit=50)
|
||||||
|
holds_to_release = [
|
||||||
|
{
|
||||||
|
'hold_id': h.id,
|
||||||
|
'name': h.name,
|
||||||
|
'job_name': (
|
||||||
|
h.x_fc_job_id.display_wo_name
|
||||||
|
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id
|
||||||
|
else (h.x_fc_job_id.name or '' if 'x_fc_job_id' in Hold._fields else '')
|
||||||
|
),
|
||||||
|
'reason': dict(Hold._fields['hold_reason'].selection).get(
|
||||||
|
h.hold_reason, h.hold_reason or '',
|
||||||
|
),
|
||||||
|
'qty': h.qty_on_hold or 0,
|
||||||
|
'requested_by': h.operator_id.name or '—',
|
||||||
|
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
|
||||||
|
}
|
||||||
|
for h in holds
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- Certs to Issue ---------------------------------------------
|
||||||
|
# Jobs where all_steps_terminal AND at least one required cert
|
||||||
|
# is still draft.
|
||||||
|
Job = env['fp.job']
|
||||||
|
job_dom = [
|
||||||
|
('all_steps_terminal', '=', True),
|
||||||
|
('state', 'not in', _NEG_JOB_STATES),
|
||||||
|
]
|
||||||
|
if facility_id:
|
||||||
|
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||||
|
terminal_jobs = Job.search(job_dom, order='write_date desc', limit=100)
|
||||||
|
certs_to_issue = []
|
||||||
|
for job in terminal_jobs:
|
||||||
|
try:
|
||||||
|
if not job._fp_has_draft_required_certs():
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
certs_to_issue.append({
|
||||||
|
'job_id': job.id,
|
||||||
|
'display_wo_name': job.display_wo_name,
|
||||||
|
'customer': job.partner_id.name or '',
|
||||||
|
'cert_types': list(job._resolve_required_cert_types()),
|
||||||
|
'all_steps_done_at': fp_format(env, job.write_date) if job.write_date else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Scrap to Review --------------------------------------------
|
||||||
|
# Recent qty_scrapped bumps via S17 hook auto-spawn holds with
|
||||||
|
# hold_reason in ('scrap', 'other'). Surface the last 24h worth.
|
||||||
|
from datetime import timedelta
|
||||||
|
scrap_cutoff = fields.Datetime.now() - timedelta(hours=24)
|
||||||
|
scrap_holds = Hold.search([
|
||||||
|
('mark_for_scrap', '=', True),
|
||||||
|
('create_date', '>=', scrap_cutoff),
|
||||||
|
], order='create_date desc', limit=20)
|
||||||
|
scrap_to_review = [
|
||||||
|
{
|
||||||
|
'hold_id': h.id,
|
||||||
|
'job_name': (
|
||||||
|
h.x_fc_job_id.display_wo_name
|
||||||
|
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id else ''
|
||||||
|
),
|
||||||
|
'scrap_qty': h.qty_on_hold or 0,
|
||||||
|
'reason': h.description or '',
|
||||||
|
'operator': h.operator_id.name or '—',
|
||||||
|
'at': fp_format(env, h.create_date) if h.create_date else '',
|
||||||
|
}
|
||||||
|
for h in scrap_holds
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'holds_to_release': holds_to_release,
|
||||||
|
'certs_to_issue': certs_to_issue,
|
||||||
|
'scrap_to_review': scrap_to_review,
|
||||||
|
'override_requests': [], # deferred — placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
|
||||||
|
def at_risk(self, facility_id=None):
|
||||||
|
"""At-Risk view: trending-late jobs + hold reasons + bottleneck.
|
||||||
|
|
||||||
|
Sub-panels:
|
||||||
|
- trending_late: top 20 jobs by late_risk_ratio desc (> 0)
|
||||||
|
- hold_reasons: open holds grouped by hold_reason
|
||||||
|
- bottleneck: work centres sorted by bottleneck_score desc
|
||||||
|
"""
|
||||||
|
env = request.env
|
||||||
|
|
||||||
|
# ---- Trending Late ----------------------------------------------
|
||||||
|
Job = env['fp.job']
|
||||||
|
job_dom = [
|
||||||
|
('state', 'not in', _NEG_JOB_STATES),
|
||||||
|
('late_risk_ratio', '>', 0),
|
||||||
|
]
|
||||||
|
if facility_id:
|
||||||
|
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||||
|
late_jobs = Job.search(
|
||||||
|
job_dom, order='late_risk_ratio desc', limit=20,
|
||||||
|
)
|
||||||
|
trending_late = [
|
||||||
|
{
|
||||||
|
'job_id': j.id,
|
||||||
|
'display_wo_name': j.display_wo_name,
|
||||||
|
'customer': j.partner_id.name or '',
|
||||||
|
'late_risk_ratio': round(j.late_risk_ratio, 2),
|
||||||
|
'deadline': fp_format(env, j.date_deadline, fmt='%Y-%m-%d') if j.date_deadline else '',
|
||||||
|
'stuck_at': (
|
||||||
|
j.active_step_id.name
|
||||||
|
if 'active_step_id' in j._fields and j.active_step_id
|
||||||
|
else ''
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for j in late_jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- Hold Reasons grouped --------------------------------------
|
||||||
|
Hold = env['fusion.plating.quality.hold']
|
||||||
|
reason_selection = dict(Hold._fields['hold_reason'].selection)
|
||||||
|
# read_group is the cheap way to bucket
|
||||||
|
groups = Hold.read_group(
|
||||||
|
domain=[('state', 'in', ('on_hold', 'under_review'))],
|
||||||
|
fields=['hold_reason'],
|
||||||
|
groupby=['hold_reason'],
|
||||||
|
)
|
||||||
|
hold_reasons = [
|
||||||
|
{
|
||||||
|
'reason': g.get('hold_reason') or 'unknown',
|
||||||
|
'label': reason_selection.get(g.get('hold_reason'), g.get('hold_reason') or 'unknown'),
|
||||||
|
'count': g.get('hold_reason_count', 0),
|
||||||
|
}
|
||||||
|
for g in groups
|
||||||
|
]
|
||||||
|
hold_reasons.sort(key=lambda r: r['count'], reverse=True)
|
||||||
|
|
||||||
|
# ---- Bottleneck heatmap ----------------------------------------
|
||||||
|
WC = env['fp.work.centre']
|
||||||
|
wc_dom = [('active', '=', True)]
|
||||||
|
if facility_id:
|
||||||
|
wc_dom.append(('facility_id', '=', int(facility_id)))
|
||||||
|
wcs = WC.search(wc_dom)
|
||||||
|
bottlenecks = []
|
||||||
|
for wc in wcs:
|
||||||
|
# Skip work centres with zero queue — no signal
|
||||||
|
if wc.bottleneck_score <= 0:
|
||||||
|
continue
|
||||||
|
bottlenecks.append({
|
||||||
|
'work_centre_id': wc.id,
|
||||||
|
'work_centre_name': wc.name,
|
||||||
|
'score': round(wc.bottleneck_score, 1),
|
||||||
|
'avg_wait_minutes': round(wc.avg_wait_minutes, 1),
|
||||||
|
})
|
||||||
|
bottlenecks.sort(key=lambda b: b['score'], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'trending_late': trending_late,
|
||||||
|
'hold_reasons': hold_reasons,
|
||||||
|
'bottleneck': bottlenecks[:10], # top 10
|
||||||
|
}
|
||||||
|
|||||||
@@ -620,15 +620,26 @@ class FpShopfloorController(http.Controller):
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Tablet Overview — one-shot dashboard payload
|
# Tablet Overview — one-shot dashboard payload
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||||
|
# New Shop Floor Landing client action (fp_shopfloor_landing) uses
|
||||||
|
# /fp/landing/kanban. The Tablet Station menu now points at the new
|
||||||
|
# surface. This endpoint stays live as long as the legacy
|
||||||
|
# fp_shopfloor_tablet OWL component is still registered — it consumes
|
||||||
|
# the rich payload (my_queue, active_wo, baths, bake_windows, gates,
|
||||||
|
# holds, pending_qcs, stations). Phase 5 cleanup will retire both the
|
||||||
|
# legacy component and this endpoint together.
|
||||||
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
|
||||||
def tablet_overview(self, station_id=None, facility_id=None):
|
def tablet_overview(self, station_id=None, facility_id=None):
|
||||||
"""Return a rich dashboard snapshot for the Tablet Station page.
|
"""[DEPRECATED] Legacy Tablet Station dashboard payload.
|
||||||
|
|
||||||
Data layer: fp.job + fp.job.step. Field names on the response
|
New consumers should use /fp/landing/kanban via the
|
||||||
keep the legacy `_wo` suffix where they were referenced from the
|
fp_shopfloor_landing client action (Phase 3 tablet redesign).
|
||||||
XML so the template doesn't need to be rewritten — internally
|
|
||||||
these now point at fp.job.step rows.
|
|
||||||
"""
|
"""
|
||||||
|
_logger.info(
|
||||||
|
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s — "
|
||||||
|
"Phase 5 cleanup will remove this endpoint.",
|
||||||
|
request.env.uid,
|
||||||
|
)
|
||||||
env = request.env
|
env = request.env
|
||||||
user = env.user
|
user = env.user
|
||||||
|
|
||||||
@@ -1002,8 +1013,20 @@ class FpShopfloorController(http.Controller):
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
|
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||||
|
# The new fp_shopfloor_landing component does NOT use this endpoint;
|
||||||
|
# it uses /fp/landing/kanban which already filters per station. The
|
||||||
|
# only remaining consumer is the legacy fp_shopfloor_tablet OWL
|
||||||
|
# component (still registered, no menu pointing at it). Phase 5
|
||||||
|
# cleanup will retire both this endpoint and the legacy component
|
||||||
|
# together — no replacement, the kanban supersedes it entirely.
|
||||||
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
|
||||||
def queue(self, facility_id=None):
|
def queue(self, facility_id=None):
|
||||||
|
_logger.info(
|
||||||
|
"DEPRECATED /fp/shopfloor/queue called by uid %s — "
|
||||||
|
"Phase 5 cleanup will remove this endpoint.",
|
||||||
|
request.env.uid,
|
||||||
|
)
|
||||||
Queue = request.env.get('fusion.plating.operator.queue')
|
Queue = request.env.get('fusion.plating.operator.queue')
|
||||||
if Queue is None or not hasattr(Queue, 'build_for_user'):
|
if Queue is None or not hasattr(Queue, 'build_for_user'):
|
||||||
# Fallback: synthesize the queue directly from fp.job.step.
|
# Fallback: synthesize the queue directly from fp.job.step.
|
||||||
@@ -1093,14 +1116,26 @@ class FpShopfloorController(http.Controller):
|
|||||||
|
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||||
|
# The new fp_shopfloor_landing client action has an "All Plant" mode
|
||||||
|
# that supersedes the standalone Plant Overview surface. Old endpoint
|
||||||
|
# stays live for the move_card sibling endpoint and the legacy
|
||||||
|
# fp_plant_overview OWL component (still registered but unhooked
|
||||||
|
# from the menu). Phase 5 cleanup will retire both together.
|
||||||
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
|
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
|
||||||
def plant_overview(self, facility_id=None, search=None):
|
def plant_overview(self, facility_id=None, search=None):
|
||||||
"""Return active fp.job.step rows grouped by fp.work.centre.
|
"""[DEPRECATED] Legacy Plant Overview payload.
|
||||||
|
|
||||||
Cards are individual fp.job.step rows in ready / in_progress /
|
New consumers should use /fp/landing/kanban with mode='all_plant'
|
||||||
paused state. Columns are fp.work.centre rows; an "Unassigned"
|
via the fp_shopfloor_landing client action (Phase 3 tablet
|
||||||
pseudo-column collects steps without a work centre.
|
redesign). Note: /fp/shopfloor/plant_overview/move_card is NOT
|
||||||
|
deprecated — the Landing component still uses it for drag-drop.
|
||||||
"""
|
"""
|
||||||
|
_logger.info(
|
||||||
|
"DEPRECATED /fp/shopfloor/plant_overview called by uid %s — "
|
||||||
|
"Phase 5 cleanup will remove this endpoint.",
|
||||||
|
request.env.uid,
|
||||||
|
)
|
||||||
env = request.env
|
env = request.env
|
||||||
search = (search or '').strip().lower()
|
search = (search or '').strip().lower()
|
||||||
|
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
}
|
||||||
@@ -14,3 +14,4 @@ access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plat
|
|||||||
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_operator,1,1,1,1
|
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_operator,1,1,1,1
|
||||||
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||||
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_job_node_override_operator,fp.job.node.override.operator,fusion_plating_jobs.model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
|
|||||||
|
@@ -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);
|
||||||
@@ -43,15 +43,27 @@ export class ManagerDashboard extends Component {
|
|||||||
// Defaults to false because lead-hand coverage often needs
|
// Defaults to false because lead-hand coverage often needs
|
||||||
// off-roster names.
|
// off-roster names.
|
||||||
hideOffShift: false,
|
hideOffShift: false,
|
||||||
|
// Phase 4 tablet redesign — 4 sibling tabs.
|
||||||
|
// funnel | inbox | plant_board | at_risk
|
||||||
|
activeTab: "funnel",
|
||||||
|
funnel: null, // /fp/manager/funnel payload
|
||||||
|
inbox: null, // /fp/manager/approval_inbox payload
|
||||||
|
atRisk: null, // /fp/manager/at_risk payload
|
||||||
});
|
});
|
||||||
|
|
||||||
this._lastHash = null; // sent to server to skip unchanged polls
|
this._lastHash = null; // sent to server to skip unchanged polls
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
|
// Load the default tab's data (Workflow Funnel) on first paint
|
||||||
|
await this.loadFunnel();
|
||||||
// 8s cadence: fast enough for production pace, light on the
|
// 8s cadence: fast enough for production pace, light on the
|
||||||
// network since unchanged payloads short-circuit server-side.
|
// network since unchanged payloads short-circuit server-side.
|
||||||
this._interval = setInterval(() => this.refresh(), 8000);
|
// The active tab's data also refreshes on each tick.
|
||||||
|
this._interval = setInterval(() => {
|
||||||
|
this.refresh();
|
||||||
|
this.refreshActiveTab();
|
||||||
|
}, 8000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onWillUnmount(() => {
|
onWillUnmount(() => {
|
||||||
@@ -283,6 +295,85 @@ export class ManagerDashboard extends Component {
|
|||||||
target: "current",
|
target: "current",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// Phase 4 tablet redesign — 4 sibling tabs
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
async setActiveTab(tab) {
|
||||||
|
if (this.state.activeTab === tab) return;
|
||||||
|
this.state.activeTab = tab;
|
||||||
|
// Load the tab's data on first switch — subsequent ticks refresh
|
||||||
|
// via the auto-poll.
|
||||||
|
await this.refreshActiveTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshActiveTab() {
|
||||||
|
if (this.state.activeTab === "funnel") return this.loadFunnel();
|
||||||
|
if (this.state.activeTab === "inbox") return this.loadInbox();
|
||||||
|
if (this.state.activeTab === "at_risk") return this.loadAtRisk();
|
||||||
|
// plant_board uses /fp/manager/overview via refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFunnel() {
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/manager/funnel", {});
|
||||||
|
if (res && res.ok) this.state.funnel = res;
|
||||||
|
} catch (err) {
|
||||||
|
this.setMessage(`Funnel: ${err.message}`, "danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadInbox() {
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/manager/approval_inbox", {});
|
||||||
|
if (res && res.ok) this.state.inbox = res;
|
||||||
|
} catch (err) {
|
||||||
|
this.setMessage(`Inbox: ${err.message}`, "danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAtRisk() {
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/manager/at_risk", {});
|
||||||
|
if (res && res.ok) this.state.atRisk = res;
|
||||||
|
} catch (err) {
|
||||||
|
this.setMessage(`At-Risk: ${err.message}`, "danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap a WO card on any tab → open the JobWorkspace (Phase 1)
|
||||||
|
openJobWorkspace(jobId) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fp_job_workspace",
|
||||||
|
params: { job_id: jobId },
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pill colour from workflow_state.color (mirrors WorkflowChip toneClass)
|
||||||
|
funnelStageTone(color) {
|
||||||
|
const map = {
|
||||||
|
grey: "muted", blue: "info", cyan: "info",
|
||||||
|
yellow: "warning", orange: "warning",
|
||||||
|
green: "success", success: "success",
|
||||||
|
danger: "danger", purple: "info",
|
||||||
|
};
|
||||||
|
return map[color] || "muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottleneck severity tone for the heatmap bar colour
|
||||||
|
bottleneckTone(score) {
|
||||||
|
if (score >= 200) return "danger";
|
||||||
|
if (score >= 60) return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
bottleneckPct(score) {
|
||||||
|
// Normalize to 0-100 for the bar width; cap at 100
|
||||||
|
return Math.min(100, Math.round(score / 5));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);
|
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
// =============================================================================
|
||||||
|
// Fusion Plating — Shop Floor Landing (OWL client action)
|
||||||
|
// Client action: fp_shopfloor_landing
|
||||||
|
//
|
||||||
|
// Replaces fp_shopfloor_tablet AND folds in fp_plant_overview. Single
|
||||||
|
// kanban entry surface for technicians. Two modes:
|
||||||
|
//
|
||||||
|
// station — paired station's work centre + Unassigned + next 1-2
|
||||||
|
// WCs in recipe flow. Default when a station is paired.
|
||||||
|
// all_plant — every active work centre. Default with no station.
|
||||||
|
//
|
||||||
|
// Tap a card → JobWorkspace. QR scan: stations pair, jobs jump.
|
||||||
|
// Drag-and-drop between columns reassigns step.work_centre_id (existing
|
||||||
|
// /fp/shopfloor/plant_overview/move_card endpoint).
|
||||||
|
//
|
||||||
|
// Auto-refresh: 15s. Mode + station_id persist in localStorage.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
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 { QrScanner } from "./qr_scanner";
|
||||||
|
import { FpKanbanCard } from "./components/kanban_card";
|
||||||
|
|
||||||
|
const LS_STATION_ID = "fp_landing_station_id";
|
||||||
|
const LS_MODE = "fp_landing_mode";
|
||||||
|
const REFRESH_MS = 15000;
|
||||||
|
|
||||||
|
export class FpShopfloorLanding extends Component {
|
||||||
|
static template = "fusion_plating_shopfloor.ShopfloorLanding";
|
||||||
|
static props = ["*"];
|
||||||
|
static components = { QrScanner, FpKanbanCard };
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.action = useService("action");
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
mode: localStorage.getItem(LS_MODE) || "all_plant",
|
||||||
|
stationId: parseInt(localStorage.getItem(LS_STATION_ID) || "0") || null,
|
||||||
|
data: null,
|
||||||
|
search: "",
|
||||||
|
scanInput: "",
|
||||||
|
showScan: false,
|
||||||
|
lastRefresh: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
this._draggedCard = null;
|
||||||
|
this._movesInFlight = 0;
|
||||||
|
this._lastDropAt = 0;
|
||||||
|
this._searchTimer = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await this.refresh();
|
||||||
|
this._refreshInterval = setInterval(() => {
|
||||||
|
if (this._movesInFlight > 0) return;
|
||||||
|
if (Date.now() - this._lastDropAt < 5000) return;
|
||||||
|
this.refresh();
|
||||||
|
}, REFRESH_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillUnmount(() => {
|
||||||
|
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
||||||
|
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Data load ---------------------------------------------------------
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/landing/kanban", {
|
||||||
|
mode: this.state.mode,
|
||||||
|
station_id: this.state.stationId,
|
||||||
|
search: this.state.search || null,
|
||||||
|
});
|
||||||
|
if (res && res.ok) {
|
||||||
|
this.state.data = res;
|
||||||
|
this.state.lastRefresh = res.server_time || new Date().toLocaleTimeString();
|
||||||
|
// If station resolved (e.g. via QR scan), persist its id
|
||||||
|
if (res.station && res.station.id) {
|
||||||
|
this.state.stationId = res.station.id;
|
||||||
|
localStorage.setItem(LS_STATION_ID, String(res.station.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message || String(err), { type: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Mode toggle -------------------------------------------------------
|
||||||
|
setMode(mode) {
|
||||||
|
if (this.state.mode === mode) return;
|
||||||
|
this.state.mode = mode;
|
||||||
|
localStorage.setItem(LS_MODE, mode);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Station picker ----------------------------------------------------
|
||||||
|
onPickStation(ev) {
|
||||||
|
const id = parseInt(ev.target.value) || null;
|
||||||
|
this.state.stationId = id;
|
||||||
|
if (id) {
|
||||||
|
localStorage.setItem(LS_STATION_ID, String(id));
|
||||||
|
// Picking a station naturally switches to station mode
|
||||||
|
this.state.mode = "station";
|
||||||
|
localStorage.setItem(LS_MODE, "station");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(LS_STATION_ID);
|
||||||
|
}
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnpairStation() {
|
||||||
|
this.state.stationId = null;
|
||||||
|
this.state.mode = "all_plant";
|
||||||
|
localStorage.removeItem(LS_STATION_ID);
|
||||||
|
localStorage.setItem(LS_MODE, "all_plant");
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Search ------------------------------------------------------------
|
||||||
|
onSearchInput(ev) {
|
||||||
|
this.state.search = ev.target.value;
|
||||||
|
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||||
|
this._searchTimer = setTimeout(() => this.refresh(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchKey(ev) {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||||
|
this.refresh();
|
||||||
|
} else if (ev.key === "Escape") {
|
||||||
|
this.state.search = "";
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tap card → JobWorkspace ------------------------------------------
|
||||||
|
onCardTap(cardData) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fp_job_workspace",
|
||||||
|
params: {
|
||||||
|
job_id: cardData.job_id,
|
||||||
|
focus_step_id: cardData.current_step_id,
|
||||||
|
},
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- QR scan -----------------------------------------------------------
|
||||||
|
toggleScan() {
|
||||||
|
this.state.showScan = !this.state.showScan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onScanSubmit() {
|
||||||
|
const code = (this.state.scanInput || "").trim();
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
this.notification.add((res && res.error) || "Unrecognised QR", { type: "danger" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.model === "fusion.plating.shopfloor.station") {
|
||||||
|
this.state.stationId = res.id;
|
||||||
|
this.state.mode = "station";
|
||||||
|
localStorage.setItem(LS_STATION_ID, String(res.id));
|
||||||
|
localStorage.setItem(LS_MODE, "station");
|
||||||
|
this.notification.add(`Paired to ${res.name}`, { type: "success" });
|
||||||
|
} else if (res.model === "fp.job") {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fp_job_workspace",
|
||||||
|
params: { job_id: res.id },
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (res.model === "fp.job.step") {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fp_job_workspace",
|
||||||
|
params: { job_id: res.job_id || 0, focus_step_id: res.id },
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.notification.add(`Scanned ${res.model}`, { type: "info" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message, { type: "danger" });
|
||||||
|
} finally {
|
||||||
|
this.state.scanInput = "";
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScanKey(ev) {
|
||||||
|
if (ev.key === "Enter") this.onScanSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Drag-and-drop -----------------------------------------------------
|
||||||
|
// Reuses the existing /fp/shopfloor/plant_overview/move_card endpoint,
|
||||||
|
// which still works for re-assigning step.work_centre_id.
|
||||||
|
onCardDragStart(card, col, ev) {
|
||||||
|
this._draggedCard = {
|
||||||
|
id: card.step_id,
|
||||||
|
source_wc_id: col.work_center_id,
|
||||||
|
};
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
ev.dataTransfer.setData("text/plain", String(card.step_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onColDragOver(col, ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
|
||||||
|
async onColDrop(col, ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const dragged = this._draggedCard;
|
||||||
|
this._draggedCard = null;
|
||||||
|
if (!dragged) return;
|
||||||
|
if (dragged.source_wc_id === col.work_center_id) return;
|
||||||
|
|
||||||
|
// Optimistic move: pop from source, push to target
|
||||||
|
const srcIdx = this.state.data.columns.findIndex(c => c.work_center_id === dragged.source_wc_id);
|
||||||
|
const tgtIdx = this.state.data.columns.findIndex(c => c.work_center_id === col.work_center_id);
|
||||||
|
let movedCard = null;
|
||||||
|
if (srcIdx >= 0 && tgtIdx >= 0) {
|
||||||
|
const src = this.state.data.columns[srcIdx].cards;
|
||||||
|
const idx = src.findIndex(c => c.step_id === dragged.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
movedCard = src[idx];
|
||||||
|
this.state.data.columns[srcIdx].cards = [
|
||||||
|
...src.slice(0, idx), ...src.slice(idx + 1),
|
||||||
|
];
|
||||||
|
this.state.data.columns[tgtIdx].cards = [
|
||||||
|
movedCard, ...this.state.data.columns[tgtIdx].cards,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._movesInFlight += 1;
|
||||||
|
this._lastDropAt = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/shopfloor/plant_overview/move_card", {
|
||||||
|
card_id: dragged.id,
|
||||||
|
target_workcenter_id: col.work_center_id,
|
||||||
|
});
|
||||||
|
if (res && res.ok) {
|
||||||
|
this.notification.add(`Moved to ${col.work_center_name}`, { type: "success" });
|
||||||
|
} else {
|
||||||
|
this.notification.add((res && res.error) || "Move failed", { type: "warning" });
|
||||||
|
await this.refresh(); // server is the source of truth on conflict
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(err.message, { type: "danger" });
|
||||||
|
await this.refresh();
|
||||||
|
} finally {
|
||||||
|
this._movesInFlight -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("fp_shopfloor_landing", FpShopfloorLanding);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -646,3 +646,230 @@
|
|||||||
display: flex; gap: $fp-space-1; margin-top: 4px;
|
display: flex; gap: $fp-space-1; margin-top: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 4 tablet redesign — Manager dashboard sibling tabs
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.o_fp_mgr_tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
border-bottom: 1px solid $fp-border;
|
||||||
|
background: $fp-card;
|
||||||
|
|
||||||
|
.o_fp_mgr_tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover { color: $fp-ink; }
|
||||||
|
&.active {
|
||||||
|
color: $fp-accent;
|
||||||
|
border-bottom-color: $fp-accent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_mgr_tab_badge {
|
||||||
|
background: $fp-accent;
|
||||||
|
color: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Workflow Funnel tab -------------------------------------------------
|
||||||
|
.o_fp_mgr_funnel {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.o_fp_funnel_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid $fp-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_funnel_stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_funnel_count {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_funnel_cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_funnel_card {
|
||||||
|
background: $fp-card;
|
||||||
|
border: 1px solid $fp-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
min-width: 130px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease, border-color 0.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, #{$fp-accent} 5%, #{$fp-card});
|
||||||
|
border-color: color-mix(in srgb, #{$fp-accent} 30%, #{$fp-border});
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_funnel_card_wo { font-weight: 600; }
|
||||||
|
.o_fp_funnel_card_meta { color: $fp-ink-soft; font-size: 0.7rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_funnel_more, .o_fp_funnel_empty {
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Approval Inbox tab --------------------------------------------------
|
||||||
|
.o_fp_mgr_inbox {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.o_fp_inbox_strip {
|
||||||
|
background: $fp-card;
|
||||||
|
border: 1px solid $fp-border;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_inbox_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-bottom: 1px dashed $fp-border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
.ms-auto { margin-left: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_empty_small {
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- At-Risk tab ---------------------------------------------------------
|
||||||
|
.o_fp_mgr_atrisk {
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.o_fp_atrisk_grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr 1.2fr;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_atrisk_card {
|
||||||
|
background: $fp-card;
|
||||||
|
border: 1px solid $fp-border;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_atrisk_row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-bottom: 1px dashed $fp-border;
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&[t-on-click], &:hover { cursor: pointer; }
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
.ms-auto { margin-left: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_atrisk_bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
|
||||||
|
.o_fp_atrisk_bar_name { min-width: 100px; }
|
||||||
|
.o_fp_atrisk_bar_track {
|
||||||
|
flex: 1;
|
||||||
|
height: 10px;
|
||||||
|
background: color-mix(in srgb, #{$fp-ink-soft} 15%, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.o_fp_atrisk_bar_fill { height: 100%; display: block; }
|
||||||
|
.o_fp_atrisk_bar_danger { background: #ff3b30; }
|
||||||
|
.o_fp_atrisk_bar_warning { background: #ff9f0a; }
|
||||||
|
.o_fp_atrisk_bar_success { background: #34c759; }
|
||||||
|
.o_fp_atrisk_bar_score { font-weight: 600; min-width: 32px; text-align: right; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_empty_small {
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// Shop Floor Landing — kanban entry surface (Phase 3 tablet redesign)
|
||||||
|
// Replaces fp_shopfloor_tablet + fp_plant_overview.
|
||||||
|
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
$o-webclient-color-scheme: bright !default;
|
||||||
|
|
||||||
|
$_lan-page-hex: #f3f4f6;
|
||||||
|
$_lan-card-hex: #ffffff;
|
||||||
|
$_lan-border-hex: #d8dadd;
|
||||||
|
$_lan-text-hex: #1d1d1f;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
$_lan-page-hex: #1a1d21 !global;
|
||||||
|
$_lan-card-hex: #22262d !global;
|
||||||
|
$_lan-border-hex: #424245 !global;
|
||||||
|
$_lan-text-hex: #f5f5f7 !global;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: $_lan-page-hex;
|
||||||
|
color: $_lan-text-hex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_loading {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
|
||||||
|
> div { margin-top: 0.6rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HEADER ------------------------------------------------------------
|
||||||
|
.o_fp_landing_head {
|
||||||
|
background: $_lan-card-hex;
|
||||||
|
border-bottom: 1px solid $_lan-border-hex;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_title_block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_station_chip {
|
||||||
|
background: rgba(0, 113, 227, 0.12);
|
||||||
|
color: #0050a0;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
.o_fp_landing_station_chip { color: #6cb6ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_unpair { padding: 0 0.2rem; color: inherit; opacity: 0.6;
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_head_actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_station_picker { min-width: 180px; }
|
||||||
|
|
||||||
|
.o_fp_landing_refresh {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scan drawer -------------------------------------------------------
|
||||||
|
.o_fp_landing_scan_drawer {
|
||||||
|
background: $_lan-card-hex;
|
||||||
|
border-bottom: 1px solid $_lan-border-hex;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- KPI strip ---------------------------------------------------------
|
||||||
|
.o_fp_landing_kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
background: $_lan-page-hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_kpi {
|
||||||
|
background: $_lan-card-hex;
|
||||||
|
border: 1px solid $_lan-border-hex;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> i {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.45rem;
|
||||||
|
right: 0.55rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_kpi_v {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_kpi_l {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.o_fp_landing_kpi_success { border-color: rgba(52, 199, 89, 0.3); }
|
||||||
|
&.o_fp_landing_kpi_warning {
|
||||||
|
border-color: rgba(255, 159, 10, 0.4);
|
||||||
|
.o_fp_landing_kpi_v { color: #b06600; }
|
||||||
|
}
|
||||||
|
&.o_fp_landing_kpi_danger {
|
||||||
|
border-color: rgba(255, 59, 48, 0.4);
|
||||||
|
background: rgba(255, 59, 48, 0.06);
|
||||||
|
.o_fp_landing_kpi_v { color: #b00018; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
.o_fp_landing_kpi_warning .o_fp_landing_kpi_v { color: #ffb84d; }
|
||||||
|
.o_fp_landing_kpi_danger .o_fp_landing_kpi_v { color: #ff7a72; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Search bar --------------------------------------------------------
|
||||||
|
.o_fp_landing_search {
|
||||||
|
background: $_lan-page-hex;
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
|
||||||
|
> i { color: var(--text-secondary, #999); font-size: 0.85rem; }
|
||||||
|
> input { max-width: 320px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Kanban board ------------------------------------------------------
|
||||||
|
.o_fp_landing_board {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 1rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_empty {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
|
||||||
|
> div { margin-top: 0.6rem; max-width: 280px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_col {
|
||||||
|
flex: 0 0 240px;
|
||||||
|
background: $_lan-card-hex;
|
||||||
|
border: 1px solid $_lan-border-hex;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
&.o_fp_drop_target {
|
||||||
|
outline: 2px dashed #0071e3;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_col_head {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-bottom: 1px solid $_lan-border-hex;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_col_name { flex: 1; }
|
||||||
|
|
||||||
|
.o_fp_landing_col_count {
|
||||||
|
background: $_lan-page-hex;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_col_body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_landing_col_empty {
|
||||||
|
color: var(--text-tertiary, #aaa);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -153,10 +153,53 @@
|
|||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Phase 4 tablet redesign — Pending Cert + At-Risk tiles -->
|
||||||
|
<div class="o_fp_kpi o_fp_kpi_warning"
|
||||||
|
t-if="state.inbox and state.inbox.certs_to_issue and state.inbox.certs_to_issue.length"
|
||||||
|
t-on-click="() => this.setActiveTab('inbox')">
|
||||||
|
<i class="fa fa-file-text"/>
|
||||||
|
<div class="o_fp_kpi_value">
|
||||||
|
<t t-esc="state.inbox.certs_to_issue.length"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_kpi_label">Pending Cert</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_kpi o_fp_kpi_danger"
|
||||||
|
t-if="state.atRisk and state.atRisk.trending_late and state.atRisk.trending_late.length"
|
||||||
|
t-on-click="() => this.setActiveTab('at_risk')">
|
||||||
|
<i class="fa fa-exclamation-triangle"/>
|
||||||
|
<div class="o_fp_kpi_value">
|
||||||
|
<t t-esc="state.atRisk.trending_late.length"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_kpi_label">At-Risk</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ Workload grid ============ -->
|
<!-- ============ Phase 4 tab navigation ============ -->
|
||||||
<div class="o_fp_manager_grid" t-if="state.overview">
|
<div class="o_fp_mgr_tabs" t-if="state.overview">
|
||||||
|
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'funnel' ? 'active' : '')"
|
||||||
|
t-on-click="() => this.setActiveTab('funnel')">
|
||||||
|
<i class="fa fa-filter"/> Workflow Funnel
|
||||||
|
</button>
|
||||||
|
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'inbox' ? 'active' : '')"
|
||||||
|
t-on-click="() => this.setActiveTab('inbox')">
|
||||||
|
<i class="fa fa-inbox"/> Approval Inbox
|
||||||
|
<span t-if="state.inbox" class="o_fp_mgr_tab_badge">
|
||||||
|
<t t-esc="(state.inbox.holds_to_release.length + state.inbox.certs_to_issue.length + state.inbox.scrap_to_review.length)"/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'plant_board' ? 'active' : '')"
|
||||||
|
t-on-click="() => this.setActiveTab('plant_board')">
|
||||||
|
<i class="fa fa-th"/> Plant Board
|
||||||
|
</button>
|
||||||
|
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'at_risk' ? 'active' : '')"
|
||||||
|
t-on-click="() => this.setActiveTab('at_risk')">
|
||||||
|
<i class="fa fa-fire"/> At-Risk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ PLANT BOARD TAB (existing 3-column grid) ============ -->
|
||||||
|
<div class="o_fp_manager_grid"
|
||||||
|
t-if="state.overview and state.activeTab === 'plant_board'">
|
||||||
|
|
||||||
<!-- Needs a Worker -->
|
<!-- Needs a Worker -->
|
||||||
<section class="o_fp_panel o_fp_panel_unassigned">
|
<section class="o_fp_panel o_fp_panel_unassigned">
|
||||||
@@ -369,6 +412,171 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ WORKFLOW FUNNEL TAB (Phase 4) ============ -->
|
||||||
|
<div class="o_fp_mgr_funnel"
|
||||||
|
t-if="state.overview and state.activeTab === 'funnel'">
|
||||||
|
<div t-if="!state.funnel" class="o_fp_empty">
|
||||||
|
<i class="fa fa-spinner fa-spin"/>
|
||||||
|
<div>Loading workflow funnel…</div>
|
||||||
|
</div>
|
||||||
|
<t t-if="state.funnel">
|
||||||
|
<div t-foreach="state.funnel.stages" t-as="stage" t-key="stage.id"
|
||||||
|
class="o_fp_funnel_row">
|
||||||
|
<div class="o_fp_funnel_stage">
|
||||||
|
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + funnelStageTone(stage.color)">
|
||||||
|
<span class="o_fp_wf_dot"/>
|
||||||
|
<span class="o_fp_wf_label" t-esc="stage.name"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_fp_funnel_count" t-esc="stage.count"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_funnel_cards">
|
||||||
|
<t t-foreach="stage.jobs" t-as="card" t-key="card.job_id">
|
||||||
|
<div class="o_fp_funnel_card"
|
||||||
|
t-on-click="() => this.openJobWorkspace(card.job_id)">
|
||||||
|
<div class="o_fp_funnel_card_wo" t-esc="card.display_wo_name"/>
|
||||||
|
<div class="o_fp_funnel_card_meta">
|
||||||
|
<t t-esc="card.customer"/> · <t t-esc="card.days_in_stage"/>d
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<span t-if="stage.count > stage.jobs.length" class="o_fp_funnel_more">
|
||||||
|
+<t t-esc="stage.count - stage.jobs.length"/> more
|
||||||
|
</span>
|
||||||
|
<span t-if="!stage.jobs.length" class="o_fp_funnel_empty">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ APPROVAL INBOX TAB (Phase 4) ============ -->
|
||||||
|
<div class="o_fp_mgr_inbox"
|
||||||
|
t-if="state.overview and state.activeTab === 'inbox'">
|
||||||
|
<div t-if="!state.inbox" class="o_fp_empty">
|
||||||
|
<i class="fa fa-spinner fa-spin"/>
|
||||||
|
<div>Loading approval inbox…</div>
|
||||||
|
</div>
|
||||||
|
<t t-if="state.inbox">
|
||||||
|
<!-- Holds to Release -->
|
||||||
|
<section class="o_fp_inbox_strip">
|
||||||
|
<h4>
|
||||||
|
<i class="fa fa-pause-circle text-danger"/>
|
||||||
|
Holds to Release (<t t-esc="state.inbox.holds_to_release.length"/>)
|
||||||
|
</h4>
|
||||||
|
<div t-if="!state.inbox.holds_to_release.length" class="o_fp_empty_small">
|
||||||
|
No open holds.
|
||||||
|
</div>
|
||||||
|
<t t-foreach="state.inbox.holds_to_release" t-as="h" t-key="h.hold_id">
|
||||||
|
<div class="o_fp_inbox_row">
|
||||||
|
<span><strong t-esc="h.name"/> · <t t-esc="h.job_name"/></span>
|
||||||
|
<span class="text-muted">· <t t-esc="h.reason"/> · qty <t t-esc="h.qty"/></span>
|
||||||
|
<span class="text-muted ms-auto"><t t-esc="h.requested_by"/> · <t t-esc="h.requested_at"/></span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
|
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.hold_id)">
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</section>
|
||||||
|
<!-- Certs to Issue -->
|
||||||
|
<section class="o_fp_inbox_strip">
|
||||||
|
<h4>
|
||||||
|
<i class="fa fa-certificate text-warning"/>
|
||||||
|
Certs to Issue (<t t-esc="state.inbox.certs_to_issue.length"/>)
|
||||||
|
</h4>
|
||||||
|
<div t-if="!state.inbox.certs_to_issue.length" class="o_fp_empty_small">
|
||||||
|
No certs waiting to be issued.
|
||||||
|
</div>
|
||||||
|
<t t-foreach="state.inbox.certs_to_issue" t-as="c" t-key="c.job_id">
|
||||||
|
<div class="o_fp_inbox_row">
|
||||||
|
<span><strong t-esc="c.display_wo_name"/> · <t t-esc="c.customer"/></span>
|
||||||
|
<span class="text-muted">· needs <t t-esc="c.cert_types.join(', ')"/></span>
|
||||||
|
<span class="text-muted ms-auto">all steps done <t t-esc="c.all_steps_done_at"/></span>
|
||||||
|
<button class="btn btn-sm btn-primary ms-2"
|
||||||
|
t-on-click="() => this.openJobWorkspace(c.job_id)">
|
||||||
|
Open Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</section>
|
||||||
|
<!-- Scrap to Review -->
|
||||||
|
<section class="o_fp_inbox_strip">
|
||||||
|
<h4>
|
||||||
|
<i class="fa fa-trash text-muted"/>
|
||||||
|
Scrap to Review (<t t-esc="state.inbox.scrap_to_review.length"/>)
|
||||||
|
</h4>
|
||||||
|
<div t-if="!state.inbox.scrap_to_review.length" class="o_fp_empty_small">
|
||||||
|
No recent scrap to acknowledge.
|
||||||
|
</div>
|
||||||
|
<t t-foreach="state.inbox.scrap_to_review" t-as="s" t-key="s.hold_id">
|
||||||
|
<div class="o_fp_inbox_row">
|
||||||
|
<span><strong t-esc="s.job_name"/> · <t t-esc="s.scrap_qty"/> scrapped</span>
|
||||||
|
<span class="text-muted" t-if="s.reason">· "<t t-esc="s.reason"/>"</span>
|
||||||
|
<span class="text-muted ms-auto"><t t-esc="s.operator"/> · <t t-esc="s.at"/></span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
|
t-on-click="() => this.openRecord('fusion.plating.quality.hold', s.hold_id)">
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</section>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ AT-RISK TAB (Phase 4) ============ -->
|
||||||
|
<div class="o_fp_mgr_atrisk"
|
||||||
|
t-if="state.overview and state.activeTab === 'at_risk'">
|
||||||
|
<div t-if="!state.atRisk" class="o_fp_empty">
|
||||||
|
<i class="fa fa-spinner fa-spin"/>
|
||||||
|
<div>Loading at-risk view…</div>
|
||||||
|
</div>
|
||||||
|
<t t-if="state.atRisk">
|
||||||
|
<div class="o_fp_atrisk_grid">
|
||||||
|
<section class="o_fp_atrisk_card">
|
||||||
|
<h4><i class="fa fa-clock-o"/> Trending Late (<t t-esc="state.atRisk.trending_late.length"/>)</h4>
|
||||||
|
<div t-if="!state.atRisk.trending_late.length" class="o_fp_empty_small">
|
||||||
|
No late-risk jobs right now.
|
||||||
|
</div>
|
||||||
|
<t t-foreach="state.atRisk.trending_late" t-as="j" t-key="j.job_id">
|
||||||
|
<div class="o_fp_atrisk_row"
|
||||||
|
t-on-click="() => this.openJobWorkspace(j.job_id)">
|
||||||
|
<span><strong t-esc="j.display_wo_name"/> · <t t-esc="j.customer"/></span>
|
||||||
|
<span t-if="j.stuck_at" class="text-muted">· stuck at <t t-esc="j.stuck_at"/></span>
|
||||||
|
<span class="text-danger ms-auto">×<t t-esc="j.late_risk_ratio"/></span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</section>
|
||||||
|
<section class="o_fp_atrisk_card">
|
||||||
|
<h4><i class="fa fa-pause-circle"/> Hold Reasons</h4>
|
||||||
|
<div t-if="!state.atRisk.hold_reasons.length" class="o_fp_empty_small">
|
||||||
|
No open holds.
|
||||||
|
</div>
|
||||||
|
<t t-foreach="state.atRisk.hold_reasons" t-as="r" t-key="r.reason">
|
||||||
|
<div class="o_fp_atrisk_row">
|
||||||
|
<span t-esc="r.label"/>
|
||||||
|
<strong class="ms-auto" t-esc="r.count"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</section>
|
||||||
|
<section class="o_fp_atrisk_card">
|
||||||
|
<h4><i class="fa fa-fire"/> Bottleneck</h4>
|
||||||
|
<div t-if="!state.atRisk.bottleneck.length" class="o_fp_empty_small">
|
||||||
|
No bottlenecks detected.
|
||||||
|
</div>
|
||||||
|
<t t-foreach="state.atRisk.bottleneck" t-as="b" t-key="b.work_centre_id">
|
||||||
|
<div class="o_fp_atrisk_bar">
|
||||||
|
<span class="o_fp_atrisk_bar_name" t-esc="b.work_centre_name"/>
|
||||||
|
<span class="o_fp_atrisk_bar_track">
|
||||||
|
<span t-att-class="'o_fp_atrisk_bar_fill o_fp_atrisk_bar_' + bottleneckTone(b.score)"
|
||||||
|
t-att-style="'width: ' + bottleneckPct(b.score) + '%'"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_fp_atrisk_bar_score" t-esc="b.score"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============ Loading ============ -->
|
<!-- ============ Loading ============ -->
|
||||||
<div t-if="!state.overview and !state.loadError" class="o_fp_empty">
|
<div t-if="!state.overview and !state.loadError" class="o_fp_empty">
|
||||||
<i class="fa fa-spinner fa-spin"/>
|
<i class="fa fa-spinner fa-spin"/>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
||||||
|
<div class="o_fp_landing">
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div t-if="!state.data" class="o_fp_landing_loading">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||||
|
<div>Loading Shop Floor…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="state.data">
|
||||||
|
|
||||||
|
<!-- ===== HEADER ===== -->
|
||||||
|
<header class="o_fp_landing_head">
|
||||||
|
<div class="o_fp_landing_title_block">
|
||||||
|
<h1 class="o_fp_landing_title">
|
||||||
|
<i class="fa fa-industry"/> Shop Floor
|
||||||
|
</h1>
|
||||||
|
<t t-if="state.data.station">
|
||||||
|
<span class="o_fp_landing_station_chip">
|
||||||
|
@ <t t-esc="state.data.station.work_center_name or state.data.station.name"/>
|
||||||
|
<button class="btn btn-sm btn-link o_fp_landing_unpair"
|
||||||
|
t-on-click="onUnpairStation"
|
||||||
|
title="Unpair this tablet">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_landing_head_actions">
|
||||||
|
<!-- Station picker -->
|
||||||
|
<select class="o_fp_landing_station_picker form-select form-select-sm"
|
||||||
|
t-on-change="onPickStation">
|
||||||
|
<option value="">— Pick station —</option>
|
||||||
|
<t t-foreach="state.data.stations" t-as="s" t-key="s.id">
|
||||||
|
<option t-att-value="s.id"
|
||||||
|
t-att-selected="state.stationId === s.id">
|
||||||
|
<t t-esc="s.name"/>
|
||||||
|
<t t-if="s.work_center_name"> · <t t-esc="s.work_center_name"/></t>
|
||||||
|
</option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="o_fp_landing_mode_toggle btn-group btn-group-sm">
|
||||||
|
<button t-att-class="'btn ' + (state.mode === 'station' ? 'btn-primary' : 'btn-outline-secondary')"
|
||||||
|
t-on-click="() => this.setMode('station')">
|
||||||
|
Station
|
||||||
|
</button>
|
||||||
|
<button t-att-class="'btn ' + (state.mode === 'all_plant' ? 'btn-primary' : 'btn-outline-secondary')"
|
||||||
|
t-on-click="() => this.setMode('all_plant')">
|
||||||
|
All Plant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scan controls -->
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleScan">
|
||||||
|
<i class="fa fa-qrcode"/> Code
|
||||||
|
</button>
|
||||||
|
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
|
||||||
|
|
||||||
|
<!-- Refresh indicator -->
|
||||||
|
<span class="o_fp_landing_refresh text-muted">
|
||||||
|
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===== Scan drawer ===== -->
|
||||||
|
<div t-if="state.showScan" class="o_fp_landing_scan_drawer">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Scan FP-STATION:… FP-JOB:… FP-STEP:…"
|
||||||
|
t-model="state.scanInput"
|
||||||
|
t-on-keydown="onScanKey"
|
||||||
|
autofocus="autofocus"/>
|
||||||
|
<button class="btn btn-primary" t-on-click="onScanSubmit">Scan</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== KPI strip (4 tech-relevant tiles) ===== -->
|
||||||
|
<div class="o_fp_landing_kpis">
|
||||||
|
<div class="o_fp_landing_kpi">
|
||||||
|
<i class="fa fa-hourglass-half"/>
|
||||||
|
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.ready"/></span>
|
||||||
|
<span class="o_fp_landing_kpi_l">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_landing_kpi o_fp_landing_kpi_success">
|
||||||
|
<i class="fa fa-cogs"/>
|
||||||
|
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.running"/></span>
|
||||||
|
<span class="o_fp_landing_kpi_l">Running</span>
|
||||||
|
</div>
|
||||||
|
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.bakes_due ? 'o_fp_landing_kpi_warning' : '')">
|
||||||
|
<i class="fa fa-fire"/>
|
||||||
|
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.bakes_due"/></span>
|
||||||
|
<span class="o_fp_landing_kpi_l">Bakes Due</span>
|
||||||
|
</div>
|
||||||
|
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.holds ? 'o_fp_landing_kpi_danger' : '')">
|
||||||
|
<i class="fa fa-pause-circle"/>
|
||||||
|
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.holds"/></span>
|
||||||
|
<span class="o_fp_landing_kpi_l">Holds</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Search bar ===== -->
|
||||||
|
<div class="o_fp_landing_search">
|
||||||
|
<i class="fa fa-search"/>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Search WO #, customer, part…"
|
||||||
|
t-model="state.search"
|
||||||
|
t-on-input="onSearchInput"
|
||||||
|
t-on-keydown="onSearchKey"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Kanban board ===== -->
|
||||||
|
<div class="o_fp_landing_board">
|
||||||
|
<div t-if="!state.data.columns.length" class="o_fp_landing_empty">
|
||||||
|
<i class="fa fa-check-circle fa-2x text-success"/>
|
||||||
|
<div t-if="state.mode === 'station'">
|
||||||
|
No jobs at this station right now. Switch to All Plant
|
||||||
|
to pull one over.
|
||||||
|
</div>
|
||||||
|
<div t-else="">
|
||||||
|
Plant is quiet — nothing in progress.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-foreach="state.data.columns" t-as="col" t-key="col.work_center_id">
|
||||||
|
<div class="o_fp_landing_col"
|
||||||
|
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
|
||||||
|
t-on-drop="(ev) => this.onColDrop(col, ev)">
|
||||||
|
<div class="o_fp_landing_col_head">
|
||||||
|
<span class="o_fp_landing_col_name" t-esc="col.work_center_name"/>
|
||||||
|
<span class="o_fp_landing_col_count"><t t-esc="col.cards.length"/></span>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_landing_col_body">
|
||||||
|
<t t-foreach="col.cards" t-as="card" t-key="card.step_id">
|
||||||
|
<div draggable="true"
|
||||||
|
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)">
|
||||||
|
<FpKanbanCard
|
||||||
|
data="card"
|
||||||
|
density="'normal'"
|
||||||
|
showWorkflowChip="true"
|
||||||
|
showWorkcenter="state.mode === 'all_plant'"
|
||||||
|
onTap.bind="onCardTap"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div t-if="!col.cards.length" class="o_fp_landing_col_empty">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import test_workspace_controller
|
||||||
|
from . import test_landing_kanban
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||||
|
"""Plan task P3.1 — /fp/landing/kanban endpoint."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo.tests.common import HttpCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
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 TestLandingKanban(HttpCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.authenticate("admin", "admin")
|
||||||
|
|
||||||
|
def test_all_plant_returns_columns_and_kpis(self):
|
||||||
|
res = _rpc(self, '/fp/landing/kanban', mode='all_plant')
|
||||||
|
self.assertTrue(res['ok'])
|
||||||
|
self.assertEqual(res['mode'], 'all_plant')
|
||||||
|
self.assertIn('columns', res)
|
||||||
|
self.assertIn('kpis', res)
|
||||||
|
for kpi in ('ready', 'running', 'bakes_due', 'holds'):
|
||||||
|
self.assertIn(kpi, res['kpis'])
|
||||||
|
self.assertIn('stations', res)
|
||||||
|
|
||||||
|
def test_station_mode_with_invalid_id_falls_back_to_all_plant_shape(self):
|
||||||
|
# No real station paired → station resolution returns None, but
|
||||||
|
# endpoint still produces a valid columns/kpis payload.
|
||||||
|
res = _rpc(self, '/fp/landing/kanban', mode='station', station_id=999999)
|
||||||
|
self.assertTrue(res['ok'])
|
||||||
|
self.assertIsNone(res['station'])
|
||||||
|
self.assertIn('columns', res)
|
||||||
@@ -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'])
|
||||||
@@ -26,14 +26,12 @@
|
|||||||
sequence="3"
|
sequence="3"
|
||||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_shopfloor_plant_overview"
|
<!-- Phase 3 tablet redesign — single Workstation menu entry replaces
|
||||||
name="Plant Overview"
|
the legacy "Tablet Station" + "Plant Overview" pair. The new
|
||||||
parent="menu_fp_shopfloor"
|
fp_shopfloor_landing component has a Station/All-Plant toggle
|
||||||
action="action_fp_plant_overview"
|
so one menu item covers both old surfaces. -->
|
||||||
sequence="5"/>
|
|
||||||
|
|
||||||
<menuitem id="menu_fp_shopfloor_tablet"
|
<menuitem id="menu_fp_shopfloor_tablet"
|
||||||
name="Tablet Station"
|
name="Workstation"
|
||||||
parent="menu_fp_shopfloor"
|
parent="menu_fp_shopfloor"
|
||||||
action="action_fp_shopfloor_tablet"
|
action="action_fp_shopfloor_tablet"
|
||||||
sequence="10"/>
|
sequence="10"/>
|
||||||
|
|||||||
@@ -7,11 +7,17 @@
|
|||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Client action — Plant Overview Dashboard -->
|
<!-- Client action — was "Plant Overview" (fp_plant_overview). -->
|
||||||
|
<!-- Phase 3 tablet redesign retargets the tag at the new -->
|
||||||
|
<!-- fp_shopfloor_landing component (it has an "All Plant" mode that -->
|
||||||
|
<!-- supersedes the standalone plant overview). Old bookmarks keep -->
|
||||||
|
<!-- working; the legacy fp_plant_overview OWL component is still -->
|
||||||
|
<!-- registered. Menu entry removed in fp_menu.xml. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="action_fp_plant_overview" model="ir.actions.client">
|
<record id="action_fp_plant_overview" model="ir.actions.client">
|
||||||
<field name="name">Plant Overview</field>
|
<field name="name">Plant Overview</field>
|
||||||
<field name="tag">fp_plant_overview</field>
|
<field name="tag">fp_shopfloor_landing</field>
|
||||||
|
<field name="params" eval="{'mode': 'all_plant'}"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
|
|||||||
@@ -89,11 +89,15 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Client action that launches the OWL tablet component -->
|
<!-- Client action — was "Tablet Station" (fp_shopfloor_tablet). -->
|
||||||
|
<!-- Phase 3 tablet redesign retargets the tag at the new -->
|
||||||
|
<!-- fp_shopfloor_landing component so old bookmarks keep working. -->
|
||||||
|
<!-- The legacy fp_shopfloor_tablet OWL component is still registered -->
|
||||||
|
<!-- (no code removed) but no menu points at it anymore. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
|
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
|
||||||
<field name="name">Tablet Station</field>
|
<field name="name">Shop Floor</field>
|
||||||
<field name="tag">fp_shopfloor_tablet</field>
|
<field name="tag">fp_shopfloor_landing</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
7
fusion_repairs/__init__.py
Normal file
7
fusion_repairs/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
|
from . import controllers
|
||||||
134
fusion_repairs/__manifest__.py
Normal file
134
fusion_repairs/__manifest__.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'Fusion Repairs',
|
||||||
|
'version': '19.0.2.1.0',
|
||||||
|
'category': 'Inventory/Repairs',
|
||||||
|
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||||
|
'description': """
|
||||||
|
Fusion Repairs
|
||||||
|
==============
|
||||||
|
|
||||||
|
Comprehensive repairs and maintenance management for medical equipment retailers
|
||||||
|
and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
|
||||||
|
walkers, mattresses, rollators).
|
||||||
|
|
||||||
|
Phase 1 - MVP
|
||||||
|
-------------
|
||||||
|
- Three intake surfaces sharing one service layer:
|
||||||
|
* Backend wizard for CS reps on the phone
|
||||||
|
* Sales rep portal (/my/repair/new) for reps on the road
|
||||||
|
* Public client self-service portal (/repair) - voicemail ready
|
||||||
|
- Guided question templates per medical equipment category
|
||||||
|
- Phone-first partner lookup with duplicate-call detection
|
||||||
|
- Multi-equipment per call (one repair.order per unit)
|
||||||
|
- Photo / video capture during intake
|
||||||
|
- Third-party equipment support (equipment we didn't sell)
|
||||||
|
- Auto warranty detection from original sale order
|
||||||
|
- Office notification recipients + 4 follow-up activities
|
||||||
|
- repair.order extensions linked to fusion.technician.task
|
||||||
|
|
||||||
|
Phase 2-4 (roadmap)
|
||||||
|
-------------------
|
||||||
|
- AI self-check engine with strict medical safety guardrails
|
||||||
|
- Upsell engine and direct-buy parts/plans
|
||||||
|
- Repair warranty tracking (free re-do window)
|
||||||
|
- Visit report wizard with Poynt terminal payment
|
||||||
|
- Maintenance contracts with client self-booking
|
||||||
|
- Weekend safety on-call paging
|
||||||
|
- SMS notifications, compliance certificates, analytics
|
||||||
|
|
||||||
|
Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||||
|
""",
|
||||||
|
'author': 'Nexa Systems Inc.',
|
||||||
|
'website': 'https://www.nexasystems.ca',
|
||||||
|
'maintainer': 'Nexa Systems Inc.',
|
||||||
|
'support': 'support@nexasystems.ca',
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'price': 0.00,
|
||||||
|
'currency': 'CAD',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'mail',
|
||||||
|
'portal',
|
||||||
|
'website',
|
||||||
|
'sale_management',
|
||||||
|
'stock',
|
||||||
|
'repair',
|
||||||
|
'maintenance',
|
||||||
|
'fusion_tasks',
|
||||||
|
'fusion_poynt',
|
||||||
|
'fusion_authorizer_portal',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
# Security
|
||||||
|
'security/security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
# Data (must load before views that reference records)
|
||||||
|
'data/ir_sequence_data.xml',
|
||||||
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'data/ir_cron_data.xml',
|
||||||
|
'data/mail_activity_type_data.xml',
|
||||||
|
'data/mail_template_data.xml',
|
||||||
|
'data/repair_product_category_data.xml',
|
||||||
|
'data/intake_template_data.xml',
|
||||||
|
'data/self_check_data.xml',
|
||||||
|
'data/emergency_charge_data.xml',
|
||||||
|
'data/callout_rate_data.xml',
|
||||||
|
'data/delivery_charge_data.xml',
|
||||||
|
# Views
|
||||||
|
'views/repair_product_category_views.xml',
|
||||||
|
'views/intake_template_views.xml',
|
||||||
|
'views/service_catalog_views.xml',
|
||||||
|
'views/repair_warranty_views.xml',
|
||||||
|
'views/maintenance_contract_views.xml',
|
||||||
|
'views/repair_dashboard_views.xml',
|
||||||
|
'views/repair_emergency_charge_views.xml',
|
||||||
|
'views/repair_inspection_views.xml',
|
||||||
|
'views/repair_callout_rate_views.xml',
|
||||||
|
'views/repair_delivery_charge_views.xml',
|
||||||
|
'views/repair_labor_warranty_views.xml',
|
||||||
|
'views/repair_order_views.xml',
|
||||||
|
'views/repair_part_order_views.xml',
|
||||||
|
'views/repair_service_plan_views.xml',
|
||||||
|
'views/sale_order_views.xml',
|
||||||
|
'views/technician_task_views.xml',
|
||||||
|
'views/res_partner_views.xml',
|
||||||
|
'views/res_users_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
# Portal templates
|
||||||
|
'views/portal_sales_rep_templates.xml',
|
||||||
|
'views/portal_client_repair_templates.xml',
|
||||||
|
'views/portal_maintenance_templates.xml',
|
||||||
|
# Wizards
|
||||||
|
'wizard/repair_intake_wizard_views.xml',
|
||||||
|
'wizard/repair_visit_report_wizard_views.xml',
|
||||||
|
'wizard/qr_sticker_wizard_views.xml',
|
||||||
|
# Reports
|
||||||
|
'report/qr_sticker_report.xml',
|
||||||
|
'report/inspection_certificate_report.xml',
|
||||||
|
# Menus (last, after all referenced actions exist)
|
||||||
|
'views/menus.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
# Tokens MUST load first - dashboard.scss references its variables.
|
||||||
|
'fusion_repairs/static/src/scss/_fr_tokens.scss',
|
||||||
|
'fusion_repairs/static/src/scss/dashboard.scss',
|
||||||
|
'fusion_repairs/static/src/components/dashboard/dashboard.js',
|
||||||
|
'fusion_repairs/static/src/components/dashboard/dashboard.xml',
|
||||||
|
],
|
||||||
|
'web.assets_frontend': [
|
||||||
|
'fusion_repairs/static/src/scss/portal_repair_mobile.scss',
|
||||||
|
'fusion_repairs/static/src/scss/portal_client_repair.scss',
|
||||||
|
'fusion_repairs/static/src/js/portal_repair_intake.js',
|
||||||
|
'fusion_repairs/static/src/js/portal_client_repair.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'images': ['static/description/icon.png'],
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
7
fusion_repairs/controllers/__init__.py
Normal file
7
fusion_repairs/controllers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from . import portal_sales_rep_repair
|
||||||
|
from . import portal_client_repair
|
||||||
|
from . import portal_maintenance_booking
|
||||||
372
fusion_repairs/controllers/portal_client_repair.py
Normal file
372
fusion_repairs/controllers/portal_client_repair.py
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
"""Public client self-service portal at /repair.
|
||||||
|
|
||||||
|
Phase 1 scope (no AI yet):
|
||||||
|
- /repair Landing page with "Start" CTA
|
||||||
|
- /repair/new Multi-step form
|
||||||
|
- /repair/submit POST -> creates repair.order via shared intake service
|
||||||
|
- /repair/thanks Confirmation with reference
|
||||||
|
- /repair/lookup_phone jsonrpc safe partner match (masked PII)
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- Public auth (no login) - the voicemail prompts mention this URL
|
||||||
|
- Per-IP rate limit on submit (configurable)
|
||||||
|
- Honeypot + CSRF
|
||||||
|
- Phone lookup returns ONLY masked name + address slice (never other PII)
|
||||||
|
- Records created via sudo in the controller; record rules don't apply
|
||||||
|
because anonymous users don't have a session
|
||||||
|
|
||||||
|
Phase 2+ will add: AI self-check, upsell engine, smart SMS verify,
|
||||||
|
safety on-call paging, reCAPTCHA v3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from odoo import SUPERUSER_ID, http, fields
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.tools import email_normalize
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory rate-limit window per worker. Good enough for Phase 1
|
||||||
|
# and matches the project's "no extra infra" goal. Resets on restart.
|
||||||
|
_RATE_LIMIT_BUCKET = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _now_hour_bucket():
|
||||||
|
return int(time.time() // 3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_partner_for_lookup(partner):
|
||||||
|
"""Return ONLY safe summary fields - never the full partner record."""
|
||||||
|
name = partner.name or ""
|
||||||
|
# First name + last initial; never reveal full surname.
|
||||||
|
if " " in name:
|
||||||
|
first, last = name.split(" ", 1)
|
||||||
|
safe_name = f"{first} {(last or ' ')[:1]}."
|
||||||
|
else:
|
||||||
|
safe_name = name
|
||||||
|
return {
|
||||||
|
"matched": True,
|
||||||
|
"name": safe_name,
|
||||||
|
"city": partner.city or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _e164_clean(phone):
|
||||||
|
if not phone:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"[^\d+]", "", phone)[-12:]
|
||||||
|
|
||||||
|
|
||||||
|
class ClientRepairPortal(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# RATE LIMIT (scoped per endpoint so /repair/self_check and
|
||||||
|
# /repair/submit and /repair/lookup_phone don't share one bucket).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _check_rate_limit(self, scope="submit"):
|
||||||
|
ICP = request.env["ir.config_parameter"].sudo()
|
||||||
|
# Scope-specific cap if configured, falls back to the global.
|
||||||
|
try:
|
||||||
|
limit = int(ICP.get_param(
|
||||||
|
f"fusion_repairs.client_portal_rate_limit_per_hour_{scope}",
|
||||||
|
ICP.get_param("fusion_repairs.client_portal_rate_limit_per_hour", "10"),
|
||||||
|
))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
limit = 10
|
||||||
|
ip = (
|
||||||
|
request.httprequest.headers.get("X-Forwarded-For")
|
||||||
|
or request.httprequest.remote_addr
|
||||||
|
or "unknown"
|
||||||
|
)
|
||||||
|
ip = ip.split(",")[0].strip()
|
||||||
|
bucket = _now_hour_bucket()
|
||||||
|
key = f"{scope}:{ip}:{bucket}"
|
||||||
|
# Prune old buckets across all scopes (cheap - dict is small).
|
||||||
|
suffix = f":{bucket}"
|
||||||
|
for k in list(_RATE_LIMIT_BUCKET.keys()):
|
||||||
|
if not k.endswith(suffix):
|
||||||
|
_RATE_LIMIT_BUCKET.pop(k, None)
|
||||||
|
if _RATE_LIMIT_BUCKET.get(key, 0) >= limit:
|
||||||
|
return True # blocked
|
||||||
|
_RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# LANDING
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/repair", type="http", auth="public", website=True, sitemap=True)
|
||||||
|
def repair_landing(self, sn=None, **kw):
|
||||||
|
serial_info = self._resolve_serial_info((sn or "").strip())
|
||||||
|
# Preserve the ?sn= in the CTA so the form gets it too.
|
||||||
|
form_url = "/repair/new" + (f"?sn={sn}" if sn else "")
|
||||||
|
return request.render("fusion_repairs.portal_client_repair_landing", {
|
||||||
|
"page_name": "client_repair_landing",
|
||||||
|
"serial_info": serial_info,
|
||||||
|
"form_url": form_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
@http.route("/repair/new", type="http", auth="public", website=True,
|
||||||
|
sitemap=False)
|
||||||
|
def repair_new(self, sn=None, **kw):
|
||||||
|
categories = request.env["fusion.repair.product.category"].sudo().search([
|
||||||
|
("active", "=", True),
|
||||||
|
], order="sequence, name")
|
||||||
|
serial_info = self._resolve_serial_info((sn or "").strip())
|
||||||
|
return request.render("fusion_repairs.portal_client_repair_form", {
|
||||||
|
"page_name": "client_repair_new",
|
||||||
|
"categories": categories,
|
||||||
|
"serial_info": serial_info,
|
||||||
|
"error": kw.get("error"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# B4: resolve ?sn=<serial> from a QR sticker scan
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _resolve_serial_info(self, serial):
|
||||||
|
if not serial:
|
||||||
|
return None
|
||||||
|
Lot = request.env["stock.lot"].sudo()
|
||||||
|
lot = Lot.search([("name", "=", serial)], limit=1)
|
||||||
|
if not lot:
|
||||||
|
return None
|
||||||
|
product = lot.product_id
|
||||||
|
category = product.product_tmpl_id.x_fc_repair_category_id
|
||||||
|
return {
|
||||||
|
"serial": lot.name,
|
||||||
|
"lot_id": lot.id,
|
||||||
|
"product_id": product.id,
|
||||||
|
"product_name": product.display_name,
|
||||||
|
"category_id": category.id if category else False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PARTNER LOOKUP (rate-limited, audited)
|
||||||
|
# The client is identifying themselves with a phone they own. We return
|
||||||
|
# enough info to pre-fill the form (name, email, street, city) plus the
|
||||||
|
# partner_id so submit can re-use the existing record instead of creating
|
||||||
|
# a duplicate. Privacy guard: rate-limited to 10/hr per IP; every match
|
||||||
|
# is logged at INFO level so abuse leaves a trail.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/repair/lookup_phone", type="jsonrpc", auth="public",
|
||||||
|
website=True)
|
||||||
|
def repair_lookup_phone(self, phone=None, **kw):
|
||||||
|
if self._check_rate_limit(scope="lookup"):
|
||||||
|
return {"error": "rate_limited"}
|
||||||
|
cleaned = _e164_clean(phone)
|
||||||
|
if len(cleaned) < 7:
|
||||||
|
return {"matched": False, "partners": []}
|
||||||
|
matches = request.env["res.partner"].sudo().search([
|
||||||
|
"|",
|
||||||
|
("phone", "ilike", cleaned[-7:]),
|
||||||
|
("phone_sanitized", "ilike", cleaned[-7:]),
|
||||||
|
], limit=3) # cap at 3 - real households rarely have more
|
||||||
|
if not matches:
|
||||||
|
return {"matched": False, "partners": []}
|
||||||
|
_logger.info(
|
||||||
|
"Portal phone lookup matched %d partner(s) for last7=%s from IP=%s",
|
||||||
|
len(matches), cleaned[-7:], request.httprequest.remote_addr,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"matched": True,
|
||||||
|
"partners": [{
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name or "",
|
||||||
|
"email": p.email or "",
|
||||||
|
"street": p.street or "",
|
||||||
|
"city": p.city or "",
|
||||||
|
} for p in matches],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SUBMIT
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/repair/submit", type="http", auth="public", methods=["POST"],
|
||||||
|
csrf=True, website=True)
|
||||||
|
def repair_submit(self, **post):
|
||||||
|
# Honeypot - bots tend to fill every visible field.
|
||||||
|
if (post.get("hp_company") or "").strip():
|
||||||
|
_logger.info("Client portal submit blocked by honeypot from IP=%s",
|
||||||
|
request.httprequest.remote_addr)
|
||||||
|
return request.redirect("/repair/new?error=spam")
|
||||||
|
|
||||||
|
if self._check_rate_limit(scope="submit"):
|
||||||
|
return request.redirect("/repair/new?error=rate_limited")
|
||||||
|
|
||||||
|
# Required fields.
|
||||||
|
partner_name = (post.get("client_name") or "").strip()
|
||||||
|
phone = (post.get("client_phone") or "").strip()
|
||||||
|
issue_summary = (post.get("issue_summary") or "").strip()
|
||||||
|
category_id = int(post.get("category_id") or 0)
|
||||||
|
|
||||||
|
if not (partner_name and phone and issue_summary and category_id):
|
||||||
|
return request.redirect("/repair/new?error=missing")
|
||||||
|
|
||||||
|
# Validate email if provided. Empty is allowed; malformed redirects back.
|
||||||
|
raw_email = (post.get("client_email") or "").strip()
|
||||||
|
clean_email = email_normalize(raw_email) if raw_email else False
|
||||||
|
if raw_email and not clean_email:
|
||||||
|
return request.redirect("/repair/new?error=email")
|
||||||
|
|
||||||
|
# B3: trust the explicit known_partner_id from the lookup widget when
|
||||||
|
# present (client identified themselves via the lookup widget on this
|
||||||
|
# very page). Otherwise re-match by phone, otherwise create.
|
||||||
|
partner = False
|
||||||
|
try:
|
||||||
|
known_id = int(post.get("known_partner_id") or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
known_id = 0
|
||||||
|
if known_id:
|
||||||
|
partner = request.env["res.partner"].sudo().browse(known_id).exists()
|
||||||
|
|
||||||
|
cleaned_phone = _e164_clean(phone)
|
||||||
|
if not partner and len(cleaned_phone) >= 7:
|
||||||
|
partner = request.env["res.partner"].sudo().search([
|
||||||
|
"|",
|
||||||
|
("phone", "ilike", cleaned_phone[-7:]),
|
||||||
|
("phone_sanitized", "ilike", cleaned_phone[-7:]),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
partner_vals = None
|
||||||
|
if not partner:
|
||||||
|
partner_vals = {
|
||||||
|
"name": partner_name,
|
||||||
|
"phone": phone,
|
||||||
|
"email": clean_email or False,
|
||||||
|
"street": (post.get("client_street") or "").strip(),
|
||||||
|
"city": (post.get("client_city") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage uploaded photos.
|
||||||
|
files = request.httprequest.files.getlist("photos")
|
||||||
|
attachment_ids = []
|
||||||
|
for f in files or []:
|
||||||
|
if not getattr(f, "filename", None):
|
||||||
|
continue
|
||||||
|
data = f.read()
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
attachment_ids.append(request.env["ir.attachment"].sudo().create({
|
||||||
|
"name": f.filename,
|
||||||
|
"datas": base64.b64encode(data),
|
||||||
|
"res_model": "fusion.repair.intake.session",
|
||||||
|
"res_id": 0,
|
||||||
|
}).id)
|
||||||
|
|
||||||
|
# B4: resolve ?sn= QR scan -> attach the lot to the repair
|
||||||
|
serial_info = self._resolve_serial_info((post.get("serial_number") or "").strip())
|
||||||
|
equipment = {
|
||||||
|
"repair_category_id": category_id,
|
||||||
|
"third_party": post.get("third_party") in ("on", "true", "1"),
|
||||||
|
"urgency": post.get("urgency") or "normal",
|
||||||
|
"issue_summary": issue_summary,
|
||||||
|
"internal_notes": (post.get("internal_notes") or "").strip(),
|
||||||
|
"photo_attachment_ids": attachment_ids,
|
||||||
|
}
|
||||||
|
if serial_info:
|
||||||
|
equipment["lot_id"] = serial_info["lot_id"]
|
||||||
|
# If client didn't override category, use what the QR identified.
|
||||||
|
if not category_id and serial_info.get("category_id"):
|
||||||
|
equipment["repair_category_id"] = serial_info["category_id"]
|
||||||
|
# Pick a real human owner for the repair so emails go from a person:
|
||||||
|
# admin if present, else the lowest-id non-share user, else SUPERUSER_ID.
|
||||||
|
admin = request.env.ref("base.user_admin", raise_if_not_found=False)
|
||||||
|
if admin:
|
||||||
|
intake_uid = admin.id
|
||||||
|
else:
|
||||||
|
internal = request.env["res.users"].sudo().search(
|
||||||
|
[("share", "=", False)], order="id asc", limit=1,
|
||||||
|
)
|
||||||
|
intake_uid = internal.id if internal else SUPERUSER_ID
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"partner_id": partner.id if partner else None,
|
||||||
|
"partner_vals": partner_vals,
|
||||||
|
"intake_user_id": intake_uid,
|
||||||
|
"equipment_items": [equipment],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
repairs = request.env["fusion.repair.intake.service"].sudo() \
|
||||||
|
.create_repair_orders(payload, source="client_portal")
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("Client portal repair submit failed")
|
||||||
|
return request.redirect("/repair/new?error=server")
|
||||||
|
|
||||||
|
token = hashlib.sha256(
|
||||||
|
f"{repairs[0].id}:{repairs[0].create_date}".encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
return request.redirect(f"/repair/thanks?ref={repairs[0].name}&t={token}")
|
||||||
|
|
||||||
|
@http.route("/repair/thanks", type="http", auth="public", website=True,
|
||||||
|
sitemap=False)
|
||||||
|
def repair_thanks(self, ref=None, t=None, **kw):
|
||||||
|
return request.render("fusion_repairs.portal_client_repair_thanks", {
|
||||||
|
"page_name": "client_repair_thanks",
|
||||||
|
"ref": ref or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CL6 / CL7: AI self-check JSONRPC endpoint
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/repair/self_check", type="jsonrpc", auth="public",
|
||||||
|
website=True)
|
||||||
|
def repair_self_check(self, category_id=None, symptoms=None,
|
||||||
|
urgency=None, **kw):
|
||||||
|
if self._check_rate_limit(scope="self_check"):
|
||||||
|
return {"error": "rate_limited"}
|
||||||
|
if not symptoms:
|
||||||
|
symptoms = []
|
||||||
|
if isinstance(symptoms, str):
|
||||||
|
symptoms = [symptoms]
|
||||||
|
# Defensive: cap input size to defend against prompt-injection bloat
|
||||||
|
symptoms = [str(s)[:500] for s in symptoms[:5]]
|
||||||
|
Service = request.env["fusion.repair.ai.service"].sudo()
|
||||||
|
return Service.suggest_self_check(
|
||||||
|
product_category_id=int(category_id or 0) or None,
|
||||||
|
symptoms=symptoms,
|
||||||
|
urgency=urgency or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CL15: on-call acknowledgement endpoint
|
||||||
|
# Only the paged user OR a Repairs Manager can ack - prevents arbitrary
|
||||||
|
# internal users (or someone with a forwarded mail) from acknowledging
|
||||||
|
# a page they were never paged for.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/repair/on-call/ack/<string:token>", type="http",
|
||||||
|
auth="user", website=True, sitemap=False)
|
||||||
|
def repair_on_call_ack(self, token, **kw):
|
||||||
|
Repair = request.env["repair.order"].sudo()
|
||||||
|
repair = Repair.search([("x_fc_on_call_token", "=", token)], limit=1)
|
||||||
|
if not repair:
|
||||||
|
return request.render(
|
||||||
|
"fusion_repairs.portal_on_call_ack_invalid", {},
|
||||||
|
)
|
||||||
|
user = request.env.user
|
||||||
|
is_paged_user = user == repair.x_fc_on_call_paged_user_id
|
||||||
|
is_manager = user.has_group("fusion_repairs.group_fusion_repairs_manager")
|
||||||
|
if not (is_paged_user or is_manager):
|
||||||
|
_logger.warning(
|
||||||
|
"On-call ack denied for repair %s - user %s is not the paged "
|
||||||
|
"user (%s) and not a Repairs Manager.",
|
||||||
|
repair.name, user.login,
|
||||||
|
repair.x_fc_on_call_paged_user_id.login or "(none)",
|
||||||
|
)
|
||||||
|
return request.render(
|
||||||
|
"fusion_repairs.portal_on_call_ack_invalid", {},
|
||||||
|
)
|
||||||
|
Service = request.env["fusion.repair.on.call.service"].sudo()
|
||||||
|
Service.acknowledge(repair, user)
|
||||||
|
return request.render("fusion_repairs.portal_on_call_ack_ok", {
|
||||||
|
"repair_name": repair.name,
|
||||||
|
})
|
||||||
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal file
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
"""Client maintenance booking portal.
|
||||||
|
|
||||||
|
The maintenance reminder email contains a tokenized URL:
|
||||||
|
/repairs/maintenance/book/<token>
|
||||||
|
|
||||||
|
Clicking it lands the client on a single-page form where they can confirm
|
||||||
|
a preferred date. On submit, a repair.order is spawned via the same
|
||||||
|
intake service (source='client_portal') and the contract's next reminder
|
||||||
|
band is locked so we don't keep nagging them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, fields, http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceBookingPortal(http.Controller):
|
||||||
|
|
||||||
|
def _resolve_contract(self, token):
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
Contract = request.env['fusion.repair.maintenance.contract'].sudo()
|
||||||
|
contract = Contract.search([('booking_token', '=', token)], limit=1)
|
||||||
|
if not contract or contract.state != 'active':
|
||||||
|
return None
|
||||||
|
return contract
|
||||||
|
|
||||||
|
@http.route('/repairs/maintenance/book/<string:token>', type='http',
|
||||||
|
auth='public', website=True, sitemap=False)
|
||||||
|
def maintenance_book_get(self, token, **kw):
|
||||||
|
contract = self._resolve_contract(token)
|
||||||
|
if not contract:
|
||||||
|
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
|
||||||
|
already = bool(contract.booking_repair_id)
|
||||||
|
return request.render('fusion_repairs.portal_maintenance_book', {
|
||||||
|
'contract': contract,
|
||||||
|
'already_booked': already,
|
||||||
|
'default_date': fields.Date.context_today(request.env.user).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
@http.route('/repairs/maintenance/book/<string:token>/confirm', type='http',
|
||||||
|
auth='public', methods=['POST'], csrf=True, website=True)
|
||||||
|
def maintenance_book_post(self, token, **post):
|
||||||
|
contract = self._resolve_contract(token)
|
||||||
|
if not contract:
|
||||||
|
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
|
||||||
|
|
||||||
|
if contract.booking_repair_id:
|
||||||
|
return request.redirect(f'/repairs/maintenance/book/{token}?ok=already')
|
||||||
|
|
||||||
|
preferred_date = (post.get('preferred_date') or '').strip()
|
||||||
|
scheduled = False
|
||||||
|
if preferred_date:
|
||||||
|
try:
|
||||||
|
scheduled = fields.Date.from_string(preferred_date)
|
||||||
|
except ValueError:
|
||||||
|
scheduled = False
|
||||||
|
|
||||||
|
repair = contract.create_repair_from_booking(scheduled_date=scheduled)
|
||||||
|
return request.render('fusion_repairs.portal_maintenance_thanks', {
|
||||||
|
'contract': contract,
|
||||||
|
'repair': repair,
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user