Compare commits
95 Commits
53fd6114e7
...
phase6_1-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6184dd2f | ||
|
|
88a473e7eb | ||
|
|
08ababc2c7 | ||
|
|
59ad77839a | ||
|
|
a594431eb6 | ||
|
|
58d02598da | ||
|
|
395bd4949e | ||
|
|
a6546ac858 | ||
|
|
233e5e6e72 | ||
|
|
b06a5b2d12 | ||
|
|
3ef67c6beb | ||
|
|
4a304e02f3 | ||
|
|
0d08d2d135 | ||
|
|
f9cb1b11ce | ||
|
|
1122f84007 | ||
|
|
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 | ||
|
|
b395600a1c | ||
|
|
612394c987 | ||
|
|
d6d6249857 | ||
|
|
3440e4b7c6 | ||
|
|
5295aefd8f | ||
|
|
4025789ba0 | ||
|
|
5b6e53c863 | ||
|
|
b70fff01e1 | ||
|
|
07f9bcf79b | ||
|
|
1420a5c445 | ||
|
|
2bfb1015ea | ||
|
|
ace82de88c | ||
|
|
1b1e9fdb9e | ||
|
|
95e0e2d9bd | ||
|
|
cdc9f864b2 | ||
|
|
a00c891277 | ||
|
|
f45883233c | ||
|
|
d5e79cdc10 | ||
|
|
1a8a96d94e | ||
|
|
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
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.8.0.7',
|
||||
'version': '19.0.9.2.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
@@ -175,6 +175,18 @@
|
||||
'fusion_claims/static/src/js/attachment_image_compress.js',
|
||||
'fusion_claims/static/src/js/debug_required_fields.js',
|
||||
'fusion_claims/static/src/xml/document_preview.xml',
|
||||
# Dashboard: tokens MUST load before dashboard layout
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
# Dashboard OWL countdown widget
|
||||
'fusion_claims/static/src/js/fc_posting_countdown.js',
|
||||
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
# Dark bundle recompiles the same SCSS with the dark
|
||||
# $o-webclient-color-scheme default so tokens branch correctly.
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
|
||||
@@ -4,159 +4,763 @@
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
CASE_TYPE_SELECTION = [
|
||||
('adp', 'ADP'),
|
||||
('odsp', 'ODSP'),
|
||||
('march_of_dimes', 'March of Dimes'),
|
||||
('hardship', 'Hardship Funding'),
|
||||
('acsd', 'ACSD'),
|
||||
('muscular_dystrophy', 'Muscular Dystrophy'),
|
||||
('insurance', 'Insurance'),
|
||||
('wsib', 'WSIB'),
|
||||
]
|
||||
|
||||
TYPE_DOMAINS = {
|
||||
'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
|
||||
'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
|
||||
'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
|
||||
'hardship': [('x_fc_sale_type', '=', 'hardship')],
|
||||
'acsd': [('x_fc_client_type', '=', 'ACS')],
|
||||
'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
|
||||
'insurance': [('x_fc_sale_type', '=', 'insurance')],
|
||||
'wsib': [('x_fc_sale_type', '=', 'wsib')],
|
||||
}
|
||||
|
||||
TYPE_LABELS = dict(CASE_TYPE_SELECTION)
|
||||
|
||||
|
||||
class FusionClaimsDashboard(models.TransientModel):
|
||||
_name = 'fusion.claims.dashboard'
|
||||
_inherit = 'fusion_claims.adp.posting.schedule.mixin'
|
||||
_description = 'Fusion Claims Dashboard'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(default='Dashboard', readonly=True)
|
||||
|
||||
# Case counts by funding type
|
||||
adp_count = fields.Integer(compute='_compute_stats')
|
||||
odsp_count = fields.Integer(compute='_compute_stats')
|
||||
march_of_dimes_count = fields.Integer(compute='_compute_stats')
|
||||
hardship_count = fields.Integer(compute='_compute_stats')
|
||||
acsd_count = fields.Integer(compute='_compute_stats')
|
||||
muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
|
||||
insurance_count = fields.Integer(compute='_compute_stats')
|
||||
wsib_count = fields.Integer(compute='_compute_stats')
|
||||
total_profiles = fields.Integer(compute='_compute_stats')
|
||||
# =========================================================================
|
||||
# Role-aware filter
|
||||
# =========================================================================
|
||||
is_manager = fields.Boolean(compute='_compute_is_manager')
|
||||
|
||||
# Panel selectors (4 panels)
|
||||
panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
|
||||
panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
|
||||
panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
|
||||
panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
|
||||
|
||||
# Panel HTML
|
||||
panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel1_title = fields.Char(compute='_compute_panels')
|
||||
panel2_title = fields.Char(compute='_compute_panels')
|
||||
panel3_title = fields.Char(compute='_compute_panels')
|
||||
panel4_title = fields.Char(compute='_compute_panels')
|
||||
|
||||
def _compute_stats(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
Profile = self.env['fusion.client.profile'].sudo()
|
||||
def _compute_is_manager(self):
|
||||
manager_group = self.env.ref('fusion_claims.group_fusion_claims_manager',
|
||||
raise_if_not_found=False)
|
||||
sale_mgr_group = self.env.ref('sales_team.group_sale_manager',
|
||||
raise_if_not_found=False)
|
||||
for rec in self:
|
||||
rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
|
||||
rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
|
||||
rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
|
||||
rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
|
||||
rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
|
||||
rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
|
||||
rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
|
||||
rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
|
||||
rec.total_profiles = Profile.search_count([])
|
||||
|
||||
@api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
|
||||
def _compute_panels(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
for i in range(1, 5):
|
||||
ptype = getattr(rec, f'panel{i}_type') or 'adp'
|
||||
domain = TYPE_DOMAINS.get(ptype, [])
|
||||
orders = SO.search(domain, order='create_date desc', limit=50)
|
||||
count = SO.search_count(domain)
|
||||
title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
|
||||
html = rec._build_top_list(orders)
|
||||
setattr(rec, f'panel{i}_title', title)
|
||||
setattr(rec, f'panel{i}_html', html)
|
||||
|
||||
def _build_top_list(self, orders):
|
||||
if not orders:
|
||||
return '<p class="text-muted text-center py-4">No cases found</p>'
|
||||
rows = []
|
||||
for o in orders:
|
||||
status = o.x_fc_adp_application_status or ''
|
||||
status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
|
||||
rows.append(
|
||||
f'<tr>'
|
||||
f'<td><a href="/odoo/sales/{o.id}">{o.name}</a></td>'
|
||||
f'<td>{o.partner_id.name or ""}</td>'
|
||||
f'<td>{status_label}</td>'
|
||||
f'<td class="text-end">${o.amount_total:,.2f}</td>'
|
||||
f'</tr>'
|
||||
user = rec.env.user
|
||||
rec.is_manager = bool(
|
||||
(manager_group and user.has_group('fusion_claims.group_fusion_claims_manager'))
|
||||
or (sale_mgr_group and user.has_group('sales_team.group_sale_manager'))
|
||||
)
|
||||
return (
|
||||
'<table class="table table-sm table-hover mb-0">'
|
||||
'<thead><tr><th>Order</th><th>Client</th><th>Status</th><th class="text-end">Total</th></tr></thead>'
|
||||
'<tbody>' + ''.join(rows) + '</tbody></table>'
|
||||
)
|
||||
|
||||
def action_open_order(self, order_id):
|
||||
"""Open a specific sale order with breadcrumbs."""
|
||||
def _role_filter_domain(self):
|
||||
"""Common domain prefix for SO-based counts.
|
||||
|
||||
Managers (fusion_claims.group_fusion_claims_manager or
|
||||
sales_team.group_sale_manager) see everything.
|
||||
Other users see only SOs where they are the salesperson.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.is_manager:
|
||||
return []
|
||||
return [('user_id', '=', self.env.user.id)]
|
||||
|
||||
def _month_start(self):
|
||||
from datetime import date
|
||||
return date.today().replace(day=1)
|
||||
|
||||
# =========================================================================
|
||||
# Header banner
|
||||
# =========================================================================
|
||||
posting_period_label = fields.Char(compute='_compute_banner')
|
||||
posting_period_start = fields.Date(compute='_compute_banner')
|
||||
posting_period_end = fields.Date(compute='_compute_banner')
|
||||
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
|
||||
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
|
||||
|
||||
def _compute_banner(self):
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import pytz
|
||||
|
||||
today = date.today()
|
||||
for rec in self:
|
||||
base_date = rec._get_adp_posting_base_date()
|
||||
rec.is_pre_first_posting = today < base_date
|
||||
|
||||
current = rec._get_current_posting_date(today)
|
||||
nxt = rec._get_next_posting_date(today)
|
||||
# If we're sitting on a posting date, current == next; treat
|
||||
# the period as the one starting today.
|
||||
if current == nxt:
|
||||
period_start = current
|
||||
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
|
||||
else:
|
||||
period_start = current
|
||||
period_end = nxt
|
||||
|
||||
rec.posting_period_start = period_start
|
||||
rec.posting_period_end = period_end
|
||||
|
||||
if rec.is_pre_first_posting:
|
||||
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
|
||||
else:
|
||||
rec.posting_period_label = (
|
||||
f"{period_start.strftime('%b %d')} – "
|
||||
f"{period_end.strftime('%b %d')}"
|
||||
)
|
||||
|
||||
wednesday = rec._get_posting_week_wednesday(nxt)
|
||||
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
|
||||
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
|
||||
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
|
||||
local_deadline = tz.localize(naive_deadline)
|
||||
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||
|
||||
# =========================================================================
|
||||
# KPI tiles (3-up)
|
||||
# =========================================================================
|
||||
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
|
||||
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
|
||||
currency_field='currency_id')
|
||||
kpi_ready_count = fields.Integer(compute='_compute_kpis')
|
||||
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
|
||||
currency_field='currency_id')
|
||||
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
|
||||
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
|
||||
currency_field='currency_id')
|
||||
kpi_ar_count = fields.Integer(compute='_compute_kpis')
|
||||
|
||||
def _invoice_role_filter(self):
|
||||
"""Role filter for invoices — applied through linked SO's user_id."""
|
||||
self.ensure_one()
|
||||
if self.is_manager:
|
||||
return []
|
||||
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
|
||||
|
||||
def _compute_kpis(self):
|
||||
Move = self.env['account.move'].sudo()
|
||||
for rec in self:
|
||||
rec.currency_id = rec.env.company.currency_id
|
||||
|
||||
inv_filter = rec._invoice_role_filter()
|
||||
|
||||
# KPI 1: Ready to Claim
|
||||
ready_domain = inv_filter + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', '=', 'waiting'),
|
||||
('adp_exported', '=', False),
|
||||
]
|
||||
ready_invoices = Move.search(ready_domain)
|
||||
rec.kpi_ready_count = len(ready_invoices)
|
||||
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
|
||||
|
||||
# KPI 2: Claimed This Period
|
||||
claimed_domain = inv_filter + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
|
||||
('adp_export_date', '>=', rec.posting_period_start),
|
||||
]
|
||||
claimed_invoices = Move.search(claimed_domain)
|
||||
rec.kpi_claimed_count = len(claimed_invoices)
|
||||
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
|
||||
|
||||
# KPI 3: Total AR (ADP-portion invoices, unpaid)
|
||||
ar_domain = inv_filter + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_invoice_type', '=', 'adp'),
|
||||
('payment_state', 'in', ['not_paid', 'partial']),
|
||||
]
|
||||
ar_invoices = Move.search(ar_domain)
|
||||
rec.kpi_ar_count = len(ar_invoices)
|
||||
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
|
||||
|
||||
# =========================================================================
|
||||
# Activities (left column)
|
||||
# =========================================================================
|
||||
my_activities_count = fields.Integer(compute='_compute_activities')
|
||||
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
|
||||
|
||||
def _compute_activities(self):
|
||||
Activity = self.env['mail.activity'].sudo()
|
||||
domain = [
|
||||
('user_id', '=', self.env.user.id),
|
||||
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
|
||||
]
|
||||
for rec in self:
|
||||
activities = Activity.search(domain, order='date_deadline asc', limit=10)
|
||||
rec.my_activities_count = Activity.search_count(domain)
|
||||
if not activities:
|
||||
rec.my_activities_html = (
|
||||
'<p class="o_fc_empty">No activities assigned.</p>'
|
||||
)
|
||||
continue
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
rows = []
|
||||
for act in activities:
|
||||
overdue = act.date_deadline and act.date_deadline < today
|
||||
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
|
||||
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else '—'
|
||||
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
|
||||
rows.append(
|
||||
f'<div class="{row_class}">'
|
||||
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
|
||||
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
rec.my_activities_html = '\n'.join(rows)
|
||||
|
||||
# =========================================================================
|
||||
# Bottlenecks (left column) + Other funder counts
|
||||
# =========================================================================
|
||||
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
|
||||
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
|
||||
|
||||
count_odsp = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_wsib = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_insurance = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_mdc = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_hardship = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_acsd = fields.Integer(compute='_compute_secondary_counts')
|
||||
|
||||
def _compute_secondary_counts(self):
|
||||
from datetime import date, timedelta
|
||||
SO = self.env['sale.order'].sudo()
|
||||
cutoff_14d_ago = date.today() - timedelta(days=14)
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
active = base + [('state', '!=', 'cancel')]
|
||||
|
||||
rec.bottleneck_no_pod_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
('x_fc_proof_of_delivery', '=', False),
|
||||
])
|
||||
rec.bottleneck_no_response_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
|
||||
])
|
||||
|
||||
rec.count_odsp = SO.search_count(active + [
|
||||
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
|
||||
])
|
||||
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
|
||||
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
|
||||
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
|
||||
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
|
||||
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
|
||||
|
||||
# =========================================================================
|
||||
# ADP Pre-Approval (right column, 4 tiles)
|
||||
# =========================================================================
|
||||
adp_waiting_app_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_app_received_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_ready_submit_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_needs_correction_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
|
||||
# =========================================================================
|
||||
# ADP Post-Approval (right column, 4 tiles)
|
||||
# =========================================================================
|
||||
adp_approved_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_ready_delivery_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_ready_bill_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_on_hold_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
|
||||
# =========================================================================
|
||||
# MOD (right column, 5 tiles)
|
||||
# =========================================================================
|
||||
mod_awaiting_funding_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_funding_approved_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_pca_received_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_project_complete_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_pod_submitted_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
|
||||
def _compute_workflow_counts(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
|
||||
# ADP Pre-Approval
|
||||
rec.adp_waiting_app_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed']),
|
||||
])
|
||||
rec.adp_app_received_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'application_received'),
|
||||
])
|
||||
rec.adp_ready_submit_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_submission'),
|
||||
])
|
||||
rec.adp_needs_correction_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'needs_correction'),
|
||||
])
|
||||
|
||||
# ADP Post-Approval
|
||||
rec.adp_approved_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
])
|
||||
rec.adp_ready_delivery_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_delivery'),
|
||||
])
|
||||
rec.adp_ready_bill_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_bill'),
|
||||
])
|
||||
rec.adp_on_hold_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'on_hold'),
|
||||
])
|
||||
|
||||
# MOD
|
||||
rec.mod_awaiting_funding_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'awaiting_funding'),
|
||||
])
|
||||
rec.mod_funding_approved_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'funding_approved'),
|
||||
])
|
||||
rec.mod_pca_received_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'contract_received'),
|
||||
])
|
||||
rec.mod_project_complete_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'project_complete'),
|
||||
])
|
||||
rec.mod_pod_submitted_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'pod_submitted'),
|
||||
])
|
||||
|
||||
# =========================================================================
|
||||
# This Month rollup (4-up secondary KPI strip)
|
||||
# =========================================================================
|
||||
count_month_submitted = fields.Integer(compute='_compute_this_month')
|
||||
count_month_approved = fields.Integer(compute='_compute_this_month')
|
||||
count_month_delivered = fields.Integer(compute='_compute_this_month')
|
||||
count_month_billed = fields.Integer(compute='_compute_this_month')
|
||||
|
||||
def _compute_this_month(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
ms = rec._month_start()
|
||||
rec.count_month_submitted = SO.search_count(base + [
|
||||
('x_fc_claim_submission_date', '>=', ms),
|
||||
])
|
||||
rec.count_month_approved = SO.search_count(base + [
|
||||
('x_fc_claim_approval_date', '>=', ms),
|
||||
])
|
||||
rec.count_month_delivered = SO.search_count(base + [
|
||||
('x_fc_adp_delivery_date', '>=', ms),
|
||||
])
|
||||
rec.count_month_billed = SO.search_count(base + [
|
||||
('x_fc_billing_date', '>=', ms),
|
||||
])
|
||||
|
||||
# =========================================================================
|
||||
# Pipeline $ by stage (4-up money-in-motion strip)
|
||||
# =========================================================================
|
||||
pipeline_pre_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
pipeline_submitted_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
pipeline_approved_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
pipeline_ready_bill_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
|
||||
def _compute_pipeline(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
pre = SO.search(base + [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed',
|
||||
'application_received', 'ready_submission']),
|
||||
])
|
||||
sub = SO.search(base + [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
])
|
||||
app = SO.search(base + [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
])
|
||||
bill = SO.search(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_bill'),
|
||||
])
|
||||
rec.pipeline_pre_amount = sum(pre.mapped('amount_total'))
|
||||
rec.pipeline_submitted_amount = sum(sub.mapped('amount_total'))
|
||||
rec.pipeline_approved_amount = sum(app.mapped('amount_total'))
|
||||
rec.pipeline_ready_bill_amount = sum(bill.mapped('amount_total'))
|
||||
|
||||
# =========================================================================
|
||||
# Aging buckets (disjoint: 30-59d, 60-89d, 90+d)
|
||||
# =========================================================================
|
||||
aging_30_count = fields.Integer(compute='_compute_aging')
|
||||
aging_60_count = fields.Integer(compute='_compute_aging')
|
||||
aging_90_count = fields.Integer(compute='_compute_aging')
|
||||
|
||||
def _compute_aging(self):
|
||||
from datetime import date, timedelta
|
||||
SO = self.env['sale.order'].sudo()
|
||||
today = date.today()
|
||||
cut_30 = today - timedelta(days=30)
|
||||
cut_60 = today - timedelta(days=60)
|
||||
cut_90 = today - timedelta(days=90)
|
||||
# "Active" = SO not cancelled at order level, AND if it has an ADP
|
||||
# status, it's not in a terminal ADP state.
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain() + [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
]
|
||||
rec.aging_30_count = SO.search_count(base + [
|
||||
('create_date', '<', cut_30),
|
||||
('create_date', '>=', cut_60),
|
||||
])
|
||||
rec.aging_60_count = SO.search_count(base + [
|
||||
('create_date', '<', cut_60),
|
||||
('create_date', '>=', cut_90),
|
||||
])
|
||||
rec.aging_90_count = SO.search_count(base + [
|
||||
('create_date', '<', cut_90),
|
||||
])
|
||||
|
||||
# =========================================================================
|
||||
# Recent ADP Exports (last 5)
|
||||
# =========================================================================
|
||||
recent_exports_html = fields.Html(compute='_compute_recent_exports',
|
||||
sanitize=False)
|
||||
recent_exports_count = fields.Integer(compute='_compute_recent_exports')
|
||||
|
||||
def _compute_recent_exports(self):
|
||||
Exp = self.env['fusion_claims.adp.export.record'].sudo()
|
||||
for rec in self:
|
||||
records = Exp.search([], order='export_date desc', limit=5)
|
||||
rec.recent_exports_count = Exp.search_count([])
|
||||
if not records:
|
||||
rec.recent_exports_html = (
|
||||
'<p class="o_fc_empty">No exports yet.</p>'
|
||||
)
|
||||
continue
|
||||
rows = []
|
||||
for r in records:
|
||||
total = sum(r.invoice_ids.mapped('amount_total'))
|
||||
date_str = (r.export_date.strftime('%b %d, %Y')
|
||||
if r.export_date else '—')
|
||||
label = r.posting_period_label or r.name or 'Export'
|
||||
inv_count = r.invoice_count or 0
|
||||
rows.append(
|
||||
f'<div class="o_fc_export_row" '
|
||||
f'data-export-id="{r.id}">'
|
||||
f'<div class="o_fc_export_label">'
|
||||
f'<b>{label}</b>'
|
||||
f'<br/><small>{date_str} · {inv_count} inv</small>'
|
||||
f'</div>'
|
||||
f'<div class="o_fc_export_amount">${total:,.0f}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
rec.recent_exports_html = '\n'.join(rows)
|
||||
|
||||
# =========================================================================
|
||||
# Open-list action methods
|
||||
# =========================================================================
|
||||
def _so_list_action(self, name, domain):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Sale Order',
|
||||
'name': name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'res_id': order_id,
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._role_filter_domain() + domain,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_adp(self):
|
||||
return self._open_type_action('adp')
|
||||
# ----- ADP Pre-Approval -----
|
||||
def action_open_adp_waiting_app(self):
|
||||
return self._so_list_action('ADP — Waiting for Application', [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed']),
|
||||
])
|
||||
|
||||
def action_open_odsp(self):
|
||||
return self._open_type_action('odsp')
|
||||
def action_open_adp_app_received(self):
|
||||
return self._so_list_action('ADP — Application Received', [
|
||||
('x_fc_adp_application_status', '=', 'application_received'),
|
||||
])
|
||||
|
||||
def action_open_march(self):
|
||||
return self._open_type_action('march_of_dimes')
|
||||
def action_open_adp_ready_submit(self):
|
||||
return self._so_list_action('ADP — Ready for Submission', [
|
||||
('x_fc_adp_application_status', '=', 'ready_submission'),
|
||||
])
|
||||
|
||||
def action_open_hardship(self):
|
||||
return self._open_type_action('hardship')
|
||||
def action_open_adp_needs_correction(self):
|
||||
return self._so_list_action('ADP — Needs Correction', [
|
||||
('x_fc_adp_application_status', '=', 'needs_correction'),
|
||||
])
|
||||
|
||||
def action_open_acsd(self):
|
||||
return self._open_type_action('acsd')
|
||||
# ----- ADP Post-Approval -----
|
||||
def action_open_adp_approved(self):
|
||||
return self._so_list_action('ADP — Approved', [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
])
|
||||
|
||||
def action_open_muscular(self):
|
||||
return self._open_type_action('muscular_dystrophy')
|
||||
def action_open_adp_ready_delivery(self):
|
||||
return self._so_list_action('ADP — Ready for Delivery', [
|
||||
('x_fc_adp_application_status', '=', 'ready_delivery'),
|
||||
])
|
||||
|
||||
def action_open_insurance(self):
|
||||
return self._open_type_action('insurance')
|
||||
def action_open_adp_ready_bill(self):
|
||||
return self._so_list_action('ADP — Ready to Bill', [
|
||||
('x_fc_adp_application_status', '=', 'ready_bill'),
|
||||
])
|
||||
|
||||
def action_open_wsib(self):
|
||||
return self._open_type_action('wsib')
|
||||
def action_open_adp_on_hold(self):
|
||||
return self._so_list_action('ADP — On Hold', [
|
||||
('x_fc_adp_application_status', '=', 'on_hold'),
|
||||
])
|
||||
|
||||
def action_open_profiles(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window', 'name': 'Client Profiles',
|
||||
'res_model': 'fusion.client.profile', 'view_mode': 'list,form',
|
||||
}
|
||||
# ----- MOD -----
|
||||
def action_open_mod_awaiting_funding(self):
|
||||
return self._so_list_action('MOD — Awaiting Funding', [
|
||||
('x_fc_mod_status', '=', 'awaiting_funding'),
|
||||
])
|
||||
|
||||
def _open_type_action(self, type_key):
|
||||
def action_open_mod_funding_approved(self):
|
||||
return self._so_list_action('MOD — Funding Approved', [
|
||||
('x_fc_mod_status', '=', 'funding_approved'),
|
||||
])
|
||||
|
||||
def action_open_mod_pca_received(self):
|
||||
return self._so_list_action('MOD — PCA Received', [
|
||||
('x_fc_mod_status', '=', 'contract_received'),
|
||||
])
|
||||
|
||||
def action_open_mod_project_complete(self):
|
||||
return self._so_list_action('MOD — Project Complete', [
|
||||
('x_fc_mod_status', '=', 'project_complete'),
|
||||
])
|
||||
|
||||
def action_open_mod_pod_submitted(self):
|
||||
return self._so_list_action('MOD — POD Submitted', [
|
||||
('x_fc_mod_status', '=', 'pod_submitted'),
|
||||
])
|
||||
|
||||
# ----- Other funders -----
|
||||
def action_open_odsp_cases(self):
|
||||
return self._so_list_action('ODSP Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
|
||||
])
|
||||
|
||||
def action_open_wsib_cases(self):
|
||||
return self._so_list_action('WSIB Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'wsib'),
|
||||
])
|
||||
|
||||
def action_open_insurance_cases(self):
|
||||
return self._so_list_action('Insurance Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'insurance'),
|
||||
])
|
||||
|
||||
def action_open_mdc_cases(self):
|
||||
return self._so_list_action('Muscular Dystrophy Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'muscular_dystrophy'),
|
||||
])
|
||||
|
||||
def action_open_hardship_cases(self):
|
||||
return self._so_list_action('Hardship Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'hardship'),
|
||||
])
|
||||
|
||||
def action_open_acsd_cases(self):
|
||||
return self._so_list_action('ACSD Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_client_type', '=', 'ACS'),
|
||||
])
|
||||
|
||||
# ----- Bottlenecks -----
|
||||
def action_open_bottleneck_no_pod(self):
|
||||
return self._so_list_action('Bottleneck — Approved without POD', [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
('x_fc_proof_of_delivery', '=', False),
|
||||
])
|
||||
|
||||
def action_open_bottleneck_no_response(self):
|
||||
from datetime import date, timedelta
|
||||
cutoff = date.today() - timedelta(days=14)
|
||||
return self._so_list_action('Bottleneck — Submitted, no response', [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
('x_fc_claim_submission_date', '<', cutoff),
|
||||
])
|
||||
|
||||
# ----- Activities -----
|
||||
def action_open_my_activities(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'{TYPE_LABELS.get(type_key, type_key)} Cases',
|
||||
'res_model': 'sale.order', 'view_mode': 'list,form',
|
||||
'domain': TYPE_DOMAINS.get(type_key, []),
|
||||
'name': 'My Activities',
|
||||
'res_model': 'mail.activity',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('user_id', '=', self.env.user.id),
|
||||
('res_model', 'in', ['sale.order', 'account.move',
|
||||
'fusion.technician.task']),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ----- KPI drill-downs -----
|
||||
def action_open_kpi_ready(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Ready to Claim (ADP)',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._invoice_role_filter() + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', '=', 'waiting'),
|
||||
('adp_exported', '=', False),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_kpi_claimed(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Claimed This Period',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._invoice_role_filter() + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
|
||||
('adp_export_date', '>=', self.posting_period_start),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_kpi_ar(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Total AR (ADP)',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._invoice_role_filter() + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_invoice_type', '=', 'adp'),
|
||||
('payment_state', 'in', ['not_paid', 'partial']),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Create-SO hotlinks
|
||||
# =========================================================================
|
||||
def _create_so_action(self, name, ctx_extra):
|
||||
context = dict(self.env.context)
|
||||
context.update(ctx_extra)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'view_id': False,
|
||||
'context': context,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_adp_so(self):
|
||||
return self._create_so_action('New ADP Order',
|
||||
{'default_x_fc_sale_type': 'adp'})
|
||||
|
||||
def action_create_mod_so(self):
|
||||
return self._create_so_action('New MOD Order',
|
||||
{'default_x_fc_sale_type': 'march_of_dimes'})
|
||||
|
||||
def action_create_odsp_so(self):
|
||||
return self._create_so_action('New ODSP Order', {
|
||||
'default_x_fc_sale_type': 'odsp',
|
||||
'default_x_fc_odsp_division': 'standard',
|
||||
})
|
||||
|
||||
def action_create_wsib_so(self):
|
||||
return self._create_so_action('New WSIB Order',
|
||||
{'default_x_fc_sale_type': 'wsib'})
|
||||
|
||||
def action_create_insurance_so(self):
|
||||
return self._create_so_action('New Insurance Order',
|
||||
{'default_x_fc_sale_type': 'insurance'})
|
||||
|
||||
def action_create_mdc_so(self):
|
||||
return self._create_so_action('New MDC Order',
|
||||
{'default_x_fc_sale_type': 'muscular_dystrophy'})
|
||||
|
||||
def action_create_hardship_so(self):
|
||||
return self._create_so_action('New Hardship Order',
|
||||
{'default_x_fc_sale_type': 'hardship'})
|
||||
|
||||
def action_create_private_so(self):
|
||||
return self._create_so_action('New Private Order',
|
||||
{'default_x_fc_sale_type': 'direct_private'})
|
||||
|
||||
# =========================================================================
|
||||
# Additional drill-downs (This Month, Pipeline, Aging, Exports)
|
||||
# =========================================================================
|
||||
def action_open_month_submitted(self):
|
||||
return self._so_list_action('Submitted This Month', [
|
||||
('x_fc_claim_submission_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_month_approved(self):
|
||||
return self._so_list_action('Approved This Month', [
|
||||
('x_fc_claim_approval_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_month_delivered(self):
|
||||
return self._so_list_action('Delivered This Month', [
|
||||
('x_fc_adp_delivery_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_month_billed(self):
|
||||
return self._so_list_action('Billed This Month', [
|
||||
('x_fc_billing_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_pipeline_pre(self):
|
||||
return self._so_list_action('Pipeline — Pre-Submission', [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed',
|
||||
'application_received', 'ready_submission']),
|
||||
])
|
||||
|
||||
def action_open_pipeline_submitted(self):
|
||||
return self._so_list_action('Pipeline — Submitted to ADP', [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
])
|
||||
|
||||
def action_open_aging_30(self):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
return self._so_list_action('Aging — 30 to 59 Days', [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
('create_date', '<', today - timedelta(days=30)),
|
||||
('create_date', '>=', today - timedelta(days=60)),
|
||||
])
|
||||
|
||||
def action_open_aging_60(self):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
return self._so_list_action('Aging — 60 to 89 Days', [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
('create_date', '<', today - timedelta(days=60)),
|
||||
('create_date', '>=', today - timedelta(days=90)),
|
||||
])
|
||||
|
||||
def action_open_aging_90(self):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
return self._so_list_action('Aging — 90+ Days', [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
('create_date', '<', today - timedelta(days=90)),
|
||||
])
|
||||
|
||||
def action_open_recent_exports(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'ADP Export History',
|
||||
'res_model': 'fusion_claims.adp.export.record',
|
||||
'view_mode': 'list,form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
63
fusion_claims/static/src/js/fc_posting_countdown.js
Normal file
63
fusion_claims/static/src/js/fc_posting_countdown.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Claims — Posting Period Countdown
|
||||
// Reads the submission_deadline_dt field, computes "Nd Xh to cutoff" client-side,
|
||||
// re-renders every 60 seconds, swaps colour class as the deadline approaches.
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
|
||||
import { Component, useState, onWillDestroy } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
class FcPostingCountdown extends Component {
|
||||
static template = "fusion_claims.PostingCountdown";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({ text: "", level: "info" });
|
||||
this._render();
|
||||
this._timer = setInterval(() => this._render(), 60_000);
|
||||
onWillDestroy(() => {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_render() {
|
||||
const deadline = this.props.record.data[this.props.name];
|
||||
if (!deadline) {
|
||||
this.state.text = "";
|
||||
this.state.level = "muted";
|
||||
return;
|
||||
}
|
||||
// Odoo provides a luxon DateTime for Datetime fields
|
||||
const now = luxon.DateTime.now();
|
||||
const diff = deadline.diff(now, ["days", "hours", "minutes"]).toObject();
|
||||
|
||||
if (diff.days < 0 || (diff.days === 0 && diff.hours < 0)) {
|
||||
this.state.text = "Cutoff passed";
|
||||
this.state.level = "muted";
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(diff.days);
|
||||
const hours = Math.floor(diff.hours);
|
||||
|
||||
if (days < 1) {
|
||||
this.state.text = `${hours}h to cutoff`;
|
||||
this.state.level = "danger";
|
||||
} else if (days < 3) {
|
||||
this.state.text = `${days}d ${hours}h to cutoff`;
|
||||
this.state.level = "warning";
|
||||
} else {
|
||||
this.state.text = `${days} days to cutoff`;
|
||||
this.state.level = "info";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fc_posting_countdown", {
|
||||
component: FcPostingCountdown,
|
||||
});
|
||||
81
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss
Normal file
81
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
// =============================================================================
|
||||
// Fusion Claims Dashboard — Palette Tokens
|
||||
// Compile-time branch on $o-webclient-color-scheme so the same SCSS file
|
||||
// produces different palettes in web.assets_backend (light) and
|
||||
// web.assets_web_dark (dark). Tokens load FIRST in each bundle.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---------- LIGHT (defaults) ----------
|
||||
$_fc-page-bg: #f7f7f8 !default;
|
||||
$_fc-card-bg: #ffffff !default;
|
||||
$_fc-card-border: #d8dadd !default;
|
||||
$_fc-text: #2b2b2b !default;
|
||||
$_fc-text-muted: #6c7480 !default;
|
||||
|
||||
$_fc-banner-from: #eef2ff !default;
|
||||
$_fc-banner-to: #fce7f3 !default;
|
||||
$_fc-banner-border: #c7d2fe !default;
|
||||
$_fc-banner-text: #3730a3 !default;
|
||||
$_fc-deadline-text: #b91c1c !default;
|
||||
|
||||
$_fc-kpi-bg: #f0f4ff !default;
|
||||
$_fc-kpi-border: #c7d2fe !default;
|
||||
$_fc-kpi-num: #1e3a8a !default;
|
||||
|
||||
$_fc-action-bg: #ecfdf5 !default;
|
||||
$_fc-action-border: #6ee7b7 !default;
|
||||
$_fc-action-text: #047857 !default;
|
||||
|
||||
$_fc-tile-bg: #f3f4f6 !default;
|
||||
$_fc-tile-border: #e5e7eb !default;
|
||||
$_fc-tile-num: #111827 !default;
|
||||
|
||||
$_fc-urgent-bg: #fee2e2 !default;
|
||||
$_fc-urgent-border: #fca5a5 !default;
|
||||
$_fc-urgent-num: #991b1b !default;
|
||||
$_fc-urgent-text: #7f1d1d !default;
|
||||
|
||||
$_fc-activity-bg: #fefce8 !default;
|
||||
$_fc-activity-border: #fde047 !default;
|
||||
$_fc-bottleneck-bg: #fef2f2 !default;
|
||||
$_fc-bottleneck-border: #fecaca !default;
|
||||
|
||||
// ---------- DARK overrides ----------
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fc-page-bg: #1a1d21 !global;
|
||||
$_fc-card-bg: #22262d !global;
|
||||
$_fc-card-border: #3a3f47 !global;
|
||||
$_fc-text: #e5e7eb !global;
|
||||
$_fc-text-muted: #9ca3af !global;
|
||||
|
||||
// Cool blue monochrome banner (selected option A from brainstorm)
|
||||
$_fc-banner-from: #1e293b !global;
|
||||
$_fc-banner-to: #1e3a5f !global;
|
||||
$_fc-banner-border: #3b82f6 !global;
|
||||
$_fc-banner-text: #93c5fd !global;
|
||||
$_fc-deadline-text: #fca5a5 !global;
|
||||
|
||||
$_fc-kpi-bg: #1e293b !global;
|
||||
$_fc-kpi-border: #334155 !global;
|
||||
$_fc-kpi-num: #93c5fd !global;
|
||||
|
||||
$_fc-action-bg: #064e3b !global;
|
||||
$_fc-action-border: #047857 !global;
|
||||
$_fc-action-text: #6ee7b7 !global;
|
||||
|
||||
$_fc-tile-bg: #2d3138 !global;
|
||||
$_fc-tile-border: #3a3f47 !global;
|
||||
$_fc-tile-num: #f3f4f6 !global;
|
||||
|
||||
$_fc-urgent-bg: #4a1414 !global;
|
||||
$_fc-urgent-border: #7f1d1d !global;
|
||||
$_fc-urgent-num: #fca5a5 !global;
|
||||
$_fc-urgent-text: #fecaca !global;
|
||||
|
||||
$_fc-activity-bg: #3a2e0a !global;
|
||||
$_fc-activity-border: #854d0e !global;
|
||||
$_fc-bottleneck-bg: #3a1414 !global;
|
||||
$_fc-bottleneck-border: #7f1d1d !global;
|
||||
}
|
||||
282
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
282
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
@@ -0,0 +1,282 @@
|
||||
// =============================================================================
|
||||
// Fusion Claims Dashboard — Layout & Section Styles
|
||||
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// Force full-width sheet on the dashboard. The sheet defaults to ~1100-1300px
|
||||
// max-width via `flex: 1 1 <fixed>` plus a CSS max-width. We override both
|
||||
// at every possible nesting level + use !important to beat media-query rules.
|
||||
// =============================================================================
|
||||
|
||||
// 1. The sheet itself
|
||||
.o_fc_dashboard_sheet,
|
||||
.o_form_sheet.o_fc_dashboard_sheet,
|
||||
.o_form_view .o_fc_dashboard_sheet,
|
||||
.o_form_renderer .o_fc_dashboard_sheet {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
flex-basis: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
// 2. The sheet-bg wrapper around the sheet
|
||||
.o_form_view:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
|
||||
.o_form_renderer:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
|
||||
.o_form_sheet_bg:has(> .o_fc_dashboard_sheet) {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
|
||||
// 3. The form view itself
|
||||
.o_form_view.o_fc_dashboard,
|
||||
.o_form_view:has(.o_fc_dashboard_sheet) {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
// 4. Legacy fallback (older Odoo selector pattern)
|
||||
.o_fc_dashboard .o_form_sheet {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
|
||||
.o_fc_dashboard {
|
||||
// Re-export tokens as CSS custom properties for devtools inspection
|
||||
--fc-page-bg: #{$_fc-page-bg};
|
||||
--fc-card-bg: #{$_fc-card-bg};
|
||||
--fc-card-border: #{$_fc-card-border};
|
||||
--fc-text: #{$_fc-text};
|
||||
--fc-text-muted: #{$_fc-text-muted};
|
||||
--fc-banner-from: #{$_fc-banner-from};
|
||||
--fc-banner-to: #{$_fc-banner-to};
|
||||
--fc-banner-border: #{$_fc-banner-border};
|
||||
--fc-banner-text: #{$_fc-banner-text};
|
||||
--fc-deadline-text: #{$_fc-deadline-text};
|
||||
--fc-kpi-bg: #{$_fc-kpi-bg};
|
||||
--fc-kpi-border: #{$_fc-kpi-border};
|
||||
--fc-kpi-num: #{$_fc-kpi-num};
|
||||
--fc-action-bg: #{$_fc-action-bg};
|
||||
--fc-action-border: #{$_fc-action-border};
|
||||
--fc-action-text: #{$_fc-action-text};
|
||||
--fc-tile-bg: #{$_fc-tile-bg};
|
||||
--fc-tile-border: #{$_fc-tile-border};
|
||||
--fc-tile-num: #{$_fc-tile-num};
|
||||
--fc-urgent-bg: #{$_fc-urgent-bg};
|
||||
--fc-urgent-border: #{$_fc-urgent-border};
|
||||
--fc-urgent-num: #{$_fc-urgent-num};
|
||||
--fc-urgent-text: #{$_fc-urgent-text};
|
||||
--fc-activity-bg: #{$_fc-activity-bg};
|
||||
--fc-activity-border: #{$_fc-activity-border};
|
||||
--fc-bottleneck-bg: #{$_fc-bottleneck-bg};
|
||||
--fc-bottleneck-border: #{$_fc-bottleneck-border};
|
||||
|
||||
background: var(--fc-page-bg);
|
||||
color: $_fc-text;
|
||||
|
||||
.o_fc_banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(90deg, var(--fc-banner-from), var(--fc-banner-to));
|
||||
border: 1px solid var(--fc-banner-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fc-banner-text);
|
||||
}
|
||||
.o_fc_banner__deadline { font-weight: 700; }
|
||||
|
||||
.o_fc_kpi {
|
||||
background: var(--fc-kpi-bg);
|
||||
border: 1px solid var(--fc-kpi-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 10px;
|
||||
text-align: center;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover { transform: translateY(-2px); }
|
||||
}
|
||||
.o_fc_kpi__num {
|
||||
display: block;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--fc-kpi-num);
|
||||
}
|
||||
.o_fc_kpi__lbl {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--fc-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
// Secondary KPI variant — smaller, denser. Used for "This Month" and
|
||||
// "Pipeline by stage" tile strips.
|
||||
.o_fc_kpi--secondary {
|
||||
padding: 10px 6px;
|
||||
.o_fc_kpi__num { font-size: 1.15rem; }
|
||||
.o_fc_kpi__lbl { font-size: 0.68rem; }
|
||||
}
|
||||
|
||||
.o_fc_actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.o_fc_pill {
|
||||
background: var(--fc-action-bg);
|
||||
border: 1px solid var(--fc-action-border);
|
||||
color: var(--fc-action-text);
|
||||
border-radius: 16px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover { background: var(--fc-action-border); }
|
||||
}
|
||||
|
||||
.o_fc_section {
|
||||
background: var(--fc-card-bg);
|
||||
border: 1px solid var(--fc-card-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.o_fc_h6 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: var(--fc-text);
|
||||
}
|
||||
.o_fc_tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--fc-banner-border);
|
||||
color: var(--fc-banner-text);
|
||||
margin-left: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.o_fc_tile {
|
||||
background: var(--fc-tile-bg);
|
||||
border: 1px solid var(--fc-tile-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 6px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
.o_fc_tile__num {
|
||||
display: block;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--fc-tile-num);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.o_fc_tile--urgent {
|
||||
background: var(--fc-urgent-bg);
|
||||
border-color: var(--fc-urgent-border);
|
||||
color: var(--fc-urgent-text);
|
||||
|
||||
.o_fc_tile__num { color: var(--fc-urgent-num); }
|
||||
}
|
||||
|
||||
.o_fc_activities {
|
||||
background: var(--fc-activity-bg);
|
||||
border: 1px solid var(--fc-activity-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.o_fc_activity_row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed var(--fc-card-border);
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.o_fc_activity_overdue {
|
||||
color: var(--fc-urgent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.o_fc_activity_deadline { color: var(--fc-text-muted); }
|
||||
.o_fc_empty {
|
||||
color: var(--fc-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.o_fc_bottleneck {
|
||||
background: var(--fc-bottleneck-bg);
|
||||
border: 1px solid var(--fc-bottleneck-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.o_fc_bottleneck_row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
color: var(--fc-text);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: var(--fc-urgent-num); text-decoration: underline; }
|
||||
}
|
||||
|
||||
// Recent ADP Exports list rows
|
||||
.o_fc_export_row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed var(--fc-card-border);
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.o_fc_export_label small {
|
||||
color: var(--fc-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.o_fc_export_amount {
|
||||
font-weight: 700;
|
||||
color: var(--fc-kpi-num);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Countdown widget colour levels (driven by OWL state)
|
||||
.o_fc_countdown {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.o_fc_countdown--info { color: var(--fc-banner-text); }
|
||||
.o_fc_countdown--warning { color: #d97706; } // amber (intentional fixed hex)
|
||||
.o_fc_countdown--danger { color: var(--fc-urgent-num); }
|
||||
.o_fc_countdown--muted { color: var(--fc-text-muted); font-style: italic; }
|
||||
}
|
||||
7
fusion_claims/static/src/xml/fc_posting_countdown.xml
Normal file
7
fusion_claims/static/src/xml/fc_posting_countdown.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_claims.PostingCountdown">
|
||||
<span t-attf-class="o_fc_countdown o_fc_countdown--{{state.level}}"
|
||||
t-esc="state.text"/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -34,6 +34,36 @@ class TestFusionClaimsDashboard(TransactionCase):
|
||||
|
||||
cls.partner = cls.Partner.create({'name': 'Test Client'})
|
||||
|
||||
@classmethod
|
||||
def _make_invoice(cls, user, billing_status, amount=1000.0,
|
||||
exported=False, export_date=None,
|
||||
invoice_type='adp', payment_state='not_paid'):
|
||||
"""Helper: create a posted ADP invoice linked to an SO owned by `user`."""
|
||||
so = cls.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': cls.partner.id,
|
||||
'user_id': user.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'approved',
|
||||
})
|
||||
invoice = cls.env['account.move'].with_context(skip_sync=True).create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': cls.partner.id,
|
||||
'x_fc_source_sale_order_id': so.id,
|
||||
'x_fc_invoice_type': invoice_type,
|
||||
'x_fc_adp_billing_status': billing_status,
|
||||
'adp_exported': exported,
|
||||
'adp_export_date': export_date,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test line',
|
||||
'quantity': 1.0,
|
||||
'price_unit': amount,
|
||||
'tax_ids': [(5, 0)], # clear taxes so amount_total == price_unit
|
||||
})],
|
||||
})
|
||||
invoice.action_post()
|
||||
invoice.with_context(skip_sync=True).write({'payment_state': payment_state})
|
||||
return invoice
|
||||
|
||||
def test_dashboard_record_creates(self):
|
||||
dashboard = self.Dashboard.create({})
|
||||
self.assertTrue(dashboard.id, "Dashboard record should be creatable")
|
||||
@@ -57,3 +87,281 @@ class TestFusionClaimsDashboard(TransactionCase):
|
||||
def test_is_manager_false_for_salesrep(self):
|
||||
dashboard = self.Dashboard.with_user(self.salesrep).create({})
|
||||
self.assertFalse(dashboard.is_manager)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 2 — Banner
|
||||
# -------------------------------------------------------------------------
|
||||
def test_banner_posting_period_label_format(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
label = dashboard.posting_period_label
|
||||
self.assertTrue(any(month in label
|
||||
for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']),
|
||||
"Label should contain a month abbreviation")
|
||||
|
||||
def test_banner_posting_period_start_and_end_are_dates(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertTrue(dashboard.posting_period_start)
|
||||
self.assertTrue(dashboard.posting_period_end)
|
||||
delta = (dashboard.posting_period_end - dashboard.posting_period_start).days
|
||||
self.assertEqual(delta, 14)
|
||||
|
||||
def test_banner_submission_deadline_is_wednesday_6pm(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
deadline = dashboard.submission_deadline_dt
|
||||
self.assertTrue(deadline, "Deadline should be set")
|
||||
# Stored in UTC; convert to user's TZ to assert the wall-clock weekday/hour
|
||||
import pytz
|
||||
tz = pytz.timezone(self.manager.tz or 'America/Toronto')
|
||||
local = pytz.UTC.localize(deadline).astimezone(tz)
|
||||
self.assertEqual(local.weekday(), 2, "Deadline should be Wednesday")
|
||||
self.assertEqual(local.hour, 18, "Deadline should be 18:00 (6 PM)")
|
||||
|
||||
def test_is_pre_first_posting_false_when_today_is_past_base_date(self):
|
||||
# Test runs after 2026-01-23 by default.
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertFalse(dashboard.is_pre_first_posting)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 3 — KPI tiles
|
||||
# -------------------------------------------------------------------------
|
||||
def test_kpi_ready_counts_waiting_invoices_not_exported(self):
|
||||
self._make_invoice(self.manager, 'waiting', amount=500.0, exported=False)
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.kpi_ready_count, 1)
|
||||
self.assertAlmostEqual(dashboard.kpi_ready_amount, 500.0, places=2)
|
||||
|
||||
def test_kpi_ready_excludes_already_exported(self):
|
||||
from datetime import date
|
||||
self._make_invoice(self.manager, 'waiting', amount=500.0,
|
||||
exported=True, export_date=date.today())
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.kpi_ready_count, 0)
|
||||
self.assertAlmostEqual(dashboard.kpi_ready_amount, 0.0, places=2)
|
||||
|
||||
def test_kpi_claimed_counts_exported_in_current_period(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
in_period_date = dashboard.posting_period_start
|
||||
self._make_invoice(self.manager, 'submitted', amount=700.0,
|
||||
exported=True, export_date=in_period_date)
|
||||
dashboard2 = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard2.kpi_claimed_count, 1)
|
||||
self.assertAlmostEqual(dashboard2.kpi_claimed_amount, 700.0, places=2)
|
||||
|
||||
def test_kpi_ar_counts_posted_unpaid_adp_invoices(self):
|
||||
self._make_invoice(self.manager, 'submitted', amount=2000.0,
|
||||
exported=True, payment_state='not_paid')
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.kpi_ar_count, 1)
|
||||
self.assertAlmostEqual(dashboard.kpi_ar_amount, 2000.0, places=2)
|
||||
|
||||
def test_kpi_ready_respects_role_filter(self):
|
||||
self._make_invoice(self.manager, 'waiting', amount=500.0)
|
||||
dashboard_rep = self.Dashboard.with_user(self.salesrep).create({})
|
||||
self.assertEqual(dashboard_rep.kpi_ready_count, 0,
|
||||
"Salesrep must not see manager's invoice")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 4 — Activities + bottlenecks
|
||||
# -------------------------------------------------------------------------
|
||||
def test_my_activities_count_zero_when_none(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.my_activities_count, 0)
|
||||
|
||||
def test_my_activities_count_picks_up_user_activity(self):
|
||||
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id,
|
||||
'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
})
|
||||
self.env['mail.activity'].create({
|
||||
'res_model_id': self.env['ir.model']._get('sale.order').id,
|
||||
'res_id': so.id,
|
||||
'res_model': 'sale.order',
|
||||
'user_id': self.manager.id,
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': 'Test activity',
|
||||
})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.my_activities_count, 1)
|
||||
self.assertIn('Test activity', dashboard.my_activities_html or '')
|
||||
|
||||
def test_bottleneck_no_pod_count(self):
|
||||
self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id,
|
||||
'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'approved',
|
||||
})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.bottleneck_no_pod_count, 1)
|
||||
|
||||
def test_bottleneck_no_response_count(self):
|
||||
from datetime import date, timedelta
|
||||
old_date = date.today() - timedelta(days=20)
|
||||
self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id,
|
||||
'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'submitted',
|
||||
'x_fc_claim_submission_date': old_date,
|
||||
})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.bottleneck_no_response_count, 1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 5 — Other funder counts
|
||||
# -------------------------------------------------------------------------
|
||||
def test_other_funder_counts_segregate_by_sale_type(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'odsp'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'wsib'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'insurance'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'muscular_dystrophy'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'hardship'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp', 'x_fc_client_type': 'ACS'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.count_odsp, 1)
|
||||
self.assertEqual(dashboard.count_wsib, 1)
|
||||
self.assertEqual(dashboard.count_insurance, 1)
|
||||
self.assertEqual(dashboard.count_mdc, 1)
|
||||
self.assertEqual(dashboard.count_hardship, 1)
|
||||
self.assertEqual(dashboard.count_acsd, 1)
|
||||
|
||||
def test_other_funder_counts_exclude_cancelled(self):
|
||||
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'wsib',
|
||||
})
|
||||
so.with_context(skip_status_validation=True).write({'state': 'cancel'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.count_wsib, 0)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 6 — ADP + MOD workflow counts
|
||||
# -------------------------------------------------------------------------
|
||||
def test_adp_pre_approval_tile_counts(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'waiting_for_application'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'application_received'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'ready_submission'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'needs_correction'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.adp_waiting_app_count, 1)
|
||||
self.assertEqual(dashboard.adp_app_received_count, 1)
|
||||
self.assertEqual(dashboard.adp_ready_submit_count, 1)
|
||||
self.assertEqual(dashboard.adp_needs_correction_count, 1)
|
||||
|
||||
def test_adp_post_approval_tile_counts(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'approved'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'ready_delivery'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'ready_bill'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'on_hold'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.adp_approved_count, 1)
|
||||
self.assertEqual(dashboard.adp_ready_delivery_count, 1)
|
||||
self.assertEqual(dashboard.adp_ready_bill_count, 1)
|
||||
self.assertEqual(dashboard.adp_on_hold_count, 1)
|
||||
|
||||
def test_mod_tile_counts(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
for status in ('awaiting_funding', 'funding_approved', 'contract_received',
|
||||
'project_complete', 'pod_submitted'):
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'march_of_dimes',
|
||||
'x_fc_mod_status': status})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.mod_awaiting_funding_count, 1)
|
||||
self.assertEqual(dashboard.mod_funding_approved_count, 1)
|
||||
self.assertEqual(dashboard.mod_pca_received_count, 1)
|
||||
self.assertEqual(dashboard.mod_project_complete_count, 1)
|
||||
self.assertEqual(dashboard.mod_pod_submitted_count, 1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 7 — Open-list action methods
|
||||
# -------------------------------------------------------------------------
|
||||
def test_action_open_adp_waiting_app_returns_correct_domain(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_adp_waiting_app()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertIn(('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed']),
|
||||
action['domain'])
|
||||
|
||||
def test_action_open_bottleneck_no_pod_returns_correct_domain(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_bottleneck_no_pod()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertIn(('x_fc_proof_of_delivery', '=', False), action['domain'])
|
||||
|
||||
def test_action_open_mod_awaiting_funding_returns_correct_domain(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_mod_awaiting_funding()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertIn(('x_fc_mod_status', '=', 'awaiting_funding'), action['domain'])
|
||||
|
||||
def test_action_open_my_activities_returns_activity_model(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_my_activities()
|
||||
self.assertEqual(action['res_model'], 'mail.activity')
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 8 — Create-SO hotlinks
|
||||
# -------------------------------------------------------------------------
|
||||
def test_action_create_adp_so_has_default_sale_type(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_create_adp_so()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertEqual(action['view_mode'], 'form')
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], 'adp')
|
||||
|
||||
def test_action_create_mod_so_has_default_sale_type(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_create_mod_so()
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], 'march_of_dimes')
|
||||
|
||||
def test_action_create_odsp_so_has_division_default(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_create_odsp_so()
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], 'odsp')
|
||||
self.assertEqual(action['context']['default_x_fc_odsp_division'], 'standard')
|
||||
|
||||
def test_all_create_so_actions_exist(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
for method_name, expected_type in [
|
||||
('action_create_adp_so', 'adp'),
|
||||
('action_create_mod_so', 'march_of_dimes'),
|
||||
('action_create_odsp_so', 'odsp'),
|
||||
('action_create_wsib_so', 'wsib'),
|
||||
('action_create_insurance_so', 'insurance'),
|
||||
('action_create_mdc_so', 'muscular_dystrophy'),
|
||||
('action_create_hardship_so', 'hardship'),
|
||||
('action_create_private_so', 'direct_private'),
|
||||
]:
|
||||
action = getattr(dashboard, method_name)()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], expected_type,
|
||||
f"{method_name} returned wrong default sale type")
|
||||
|
||||
@@ -4,151 +4,536 @@
|
||||
<field name="name">fusion.claims.dashboard.form</field>
|
||||
<field name="model">fusion.claims.dashboard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Dashboard" create="0" delete="0">
|
||||
<sheet>
|
||||
<!-- ===== FUNDING CARDS (one line, bigger) ===== -->
|
||||
<div class="d-flex flex-nowrap gap-2 mb-4 overflow-auto">
|
||||
<div invisible="adp_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_adp" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="adp_count"/></div>
|
||||
<div style="font-size: 0.85rem;">ADP</div>
|
||||
</div>
|
||||
</button>
|
||||
<form string="Dashboard" create="0" delete="0" edit="0"
|
||||
class="o_fc_dashboard">
|
||||
<sheet class="o_fc_dashboard_sheet">
|
||||
|
||||
<!-- Hidden invariants used by buttons + widgets -->
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="posting_period_start" invisible="1"/>
|
||||
<field name="is_manager" invisible="1"/>
|
||||
<field name="is_pre_first_posting" invisible="1"/>
|
||||
|
||||
<!-- BANNER -->
|
||||
<div class="o_fc_banner mb-3">
|
||||
<div class="o_fc_banner__label">
|
||||
<i class="fa fa-calendar me-2"/>
|
||||
<span>Posting Period: </span>
|
||||
<field name="posting_period_label" nolabel="1"
|
||||
class="fw-bold"/>
|
||||
</div>
|
||||
<div invisible="odsp_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_odsp" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="odsp_count"/></div>
|
||||
<div style="font-size: 0.85rem;">ODSP</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="march_of_dimes_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_march" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="march_of_dimes_count"/></div>
|
||||
<div style="font-size: 0.85rem;">March of Dimes</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="hardship_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_hardship" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="hardship_count"/></div>
|
||||
<div style="font-size: 0.85rem;">Hardship</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="acsd_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_acsd" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="acsd_count"/></div>
|
||||
<div style="font-size: 0.85rem;">ACSD</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="muscular_dystrophy_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_muscular" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="muscular_dystrophy_count"/></div>
|
||||
<div style="font-size: 0.85rem;">Muscular Dystrophy</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="insurance_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_insurance" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="insurance_count"/></div>
|
||||
<div style="font-size: 0.85rem;">Insurance</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="wsib_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_wsib" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="wsib_count"/></div>
|
||||
<div style="font-size: 0.85rem;">WSIB</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="total_profiles == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_profiles" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="total_profiles"/></div>
|
||||
<div style="font-size: 0.85rem;">Profiles</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="o_fc_banner__deadline">
|
||||
<field name="submission_deadline_dt"
|
||||
widget="fc_posting_countdown"
|
||||
nolabel="1" readonly="1"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== PANEL SELECTORS (4 dropdowns) ===== -->
|
||||
<!-- "Showing your cases" hint when role-filtered -->
|
||||
<div class="alert alert-info py-2 mb-2"
|
||||
invisible="is_manager">
|
||||
Showing your assigned cases only.
|
||||
</div>
|
||||
|
||||
<!-- KPI TILES (3-up) -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 1</div>
|
||||
<field name="panel1_type" nolabel="1"/>
|
||||
<div class="col-12 col-md-4">
|
||||
<button name="action_open_kpi_ready" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="kpi_ready_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Ready to Claim
|
||||
(<field name="kpi_ready_count" nolabel="1"/>)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 2</div>
|
||||
<field name="panel2_type" nolabel="1"/>
|
||||
<div class="col-12 col-md-4">
|
||||
<button name="action_open_kpi_claimed" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="kpi_claimed_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Claimed This Period
|
||||
(<field name="kpi_claimed_count" nolabel="1"/>)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 3</div>
|
||||
<field name="panel3_type" nolabel="1"/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 4</div>
|
||||
<field name="panel4_type" nolabel="1"/>
|
||||
<div class="col-12 col-md-4">
|
||||
<button name="action_open_kpi_ar" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="kpi_ar_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Total AR
|
||||
(<field name="kpi_ar_count" nolabel="1"/>)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== TOP PANELS ROW 1 ===== -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<field name="panel1_title" nolabel="1"/>
|
||||
<!-- THIS MONTH ROLLUP (4 count tiles) -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_submitted" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_submitted" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Submitted MTD</span>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel1_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||
<field name="panel2_title" nolabel="1"/>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_approved" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Approved MTD</span>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel2_html" class="w-100" nolabel="1"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_delivered" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_delivered" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Delivered MTD</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_billed" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_billed" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Billed MTD</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== TOP PANELS ROW 2 ===== -->
|
||||
<!-- PIPELINE $ BY STAGE (4 amount tiles) -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_pipeline_pre" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_pre_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Pre-Submit</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_pipeline_submitted" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_submitted_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Submitted</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_approved_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Approved</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_bill" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_ready_bill_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Ready to Bill</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUICK ACTION PILLS -->
|
||||
<div class="o_fc_actions mb-3">
|
||||
<button name="action_create_adp_so" type="object"
|
||||
class="o_fc_pill">+ ADP</button>
|
||||
<button name="action_create_mod_so" type="object"
|
||||
class="o_fc_pill">+ MOD</button>
|
||||
<button name="action_create_odsp_so" type="object"
|
||||
class="o_fc_pill">+ ODSP</button>
|
||||
<button name="action_create_wsib_so" type="object"
|
||||
class="o_fc_pill">+ WSIB</button>
|
||||
<button name="action_create_insurance_so" type="object"
|
||||
class="o_fc_pill">+ Insurance</button>
|
||||
<button name="action_create_mdc_so" type="object"
|
||||
class="o_fc_pill">+ MDC</button>
|
||||
<button name="action_create_hardship_so" type="object"
|
||||
class="o_fc_pill">+ Hardship</button>
|
||||
<button name="action_create_private_so" type="object"
|
||||
class="o_fc_pill">+ Private</button>
|
||||
</div>
|
||||
|
||||
<!-- RESPONSIVE GRID — 5/7 on lg, 3/5/4 on xl (≥1200px) -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
|
||||
<field name="panel3_title" nolabel="1"/>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel3_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- COLUMN 1: Personal / actionable (Activities + Bottlenecks) -->
|
||||
<div class="col-12 col-lg-5 col-xl-3">
|
||||
|
||||
<!-- Your Activities -->
|
||||
<div class="o_fc_activities mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-thumb-tack me-2"/>
|
||||
Your Activities
|
||||
<span class="o_fc_tag">
|
||||
<field name="my_activities_count" nolabel="1"/>
|
||||
</span>
|
||||
<button name="action_open_my_activities" type="object"
|
||||
class="btn btn-link btn-sm ms-auto p-0">
|
||||
View all
|
||||
</button>
|
||||
</h6>
|
||||
<field name="my_activities_html" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Bottlenecks -->
|
||||
<div class="o_fc_bottleneck mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-exclamation-triangle me-2"/>
|
||||
Bottlenecks
|
||||
</h6>
|
||||
<button name="action_open_bottleneck_no_pod" type="object"
|
||||
class="o_fc_bottleneck_row btn btn-link p-0">
|
||||
Approved without POD:
|
||||
<span class="fw-bold ms-1">
|
||||
<field name="bottleneck_no_pod_count" nolabel="1"/>
|
||||
</span>
|
||||
</button>
|
||||
<button name="action_open_bottleneck_no_response" type="object"
|
||||
class="o_fc_bottleneck_row btn btn-link p-0">
|
||||
Submitted > 14d, no response:
|
||||
<span class="fw-bold ms-1">
|
||||
<field name="bottleneck_no_response_count" nolabel="1"/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
|
||||
<field name="panel4_title" nolabel="1"/>
|
||||
|
||||
<!-- COLUMN 2: Workflow center (ADP + MOD) -->
|
||||
<div class="col-12 col-lg-7 col-xl-5">
|
||||
|
||||
<!-- ADP Pre-Approval -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">ADP
|
||||
<span class="o_fc_tag">Pre-Approval</span>
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_waiting_app" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_waiting_app_count" nolabel="1"/>
|
||||
</span>Waiting App
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_app_received" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_app_received_count" nolabel="1"/>
|
||||
</span>App Received
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_submit" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_ready_submit_count" nolabel="1"/>
|
||||
</span>Ready Submit
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_needs_correction" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_needs_correction_count" nolabel="1"/>
|
||||
</span>Needs Correction
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel4_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- ADP Post-Approval -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">ADP
|
||||
<span class="o_fc_tag">Post-Approval</span>
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_approved_count" nolabel="1"/>
|
||||
</span>Approved
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_delivery" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_ready_delivery_count" nolabel="1"/>
|
||||
</span>Ready Delivery
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_bill" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_ready_bill_count" nolabel="1"/>
|
||||
</span>Ready Bill
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_on_hold" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_on_hold_count" nolabel="1"/>
|
||||
</span>On Hold
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOD -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">MOD</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-2">
|
||||
<button name="action_open_mod_awaiting_funding" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_awaiting_funding_count" nolabel="1"/>
|
||||
</span>Awaiting
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<button name="action_open_mod_funding_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_funding_approved_count" nolabel="1"/>
|
||||
</span>Approved
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<button name="action_open_mod_pca_received" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_pca_received_count" nolabel="1"/>
|
||||
</span>PCA
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_mod_project_complete" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_project_complete_count" nolabel="1"/>
|
||||
</span>Proj. Done
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_mod_pod_submitted" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_pod_submitted_count" nolabel="1"/>
|
||||
</span>POD Submitted
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COLUMN 3: Analytics (Aging + Other Funders + Recent Exports)
|
||||
Full-width below cols 1+2 on lg, dedicated right-column on xl -->
|
||||
<div class="col-12 col-xl-4">
|
||||
|
||||
<!-- Aging buckets -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-clock-o me-2"/>
|
||||
Aging
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button name="action_open_aging_30" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="aging_30_count" nolabel="1"/>
|
||||
</span>30 – 59d
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_aging_60" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="aging_60_count" nolabel="1"/>
|
||||
</span>60 – 89d
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_aging_90" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="aging_90_count" nolabel="1"/>
|
||||
</span>90+ d
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Funders -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">Other Funders</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button name="action_open_odsp_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_odsp" nolabel="1"/>
|
||||
</span>ODSP
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_wsib_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_wsib" nolabel="1"/>
|
||||
</span>WSIB
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_insurance_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_insurance" nolabel="1"/>
|
||||
</span>Insurance
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_mdc_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_mdc" nolabel="1"/>
|
||||
</span>MDC
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_hardship_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_hardship" nolabel="1"/>
|
||||
</span>Hardship
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_acsd_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_acsd" nolabel="1"/>
|
||||
</span>ACSD
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent ADP Exports (last 5) -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-file-text-o me-2"/>
|
||||
Recent ADP Exports
|
||||
<span class="o_fc_tag">
|
||||
<field name="recent_exports_count" nolabel="1"/>
|
||||
</span>
|
||||
<button name="action_open_recent_exports" type="object"
|
||||
class="btn btn-link btn-sm ms-auto p-0">
|
||||
View all
|
||||
</button>
|
||||
</h6>
|
||||
<field name="recent_exports_html" nolabel="1"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
@@ -162,4 +547,13 @@
|
||||
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<!-- Dashboard Menu — top of the Fusion Claims app, sequence=1 so it
|
||||
renders before "All Orders" (sequence=2) and becomes the default
|
||||
landing when clicking the app icon. -->
|
||||
<menuitem id="menu_fusion_claims_dashboard"
|
||||
name="Dashboard"
|
||||
parent="menu_adp_claims_root"
|
||||
action="action_fusion_claims_dashboard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Faxes',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.1.1',
|
||||
'category': 'Productivity',
|
||||
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
||||
'description': """
|
||||
|
||||
@@ -32,5 +32,13 @@
|
||||
<field name="value"></field>
|
||||
</record>
|
||||
|
||||
<!-- UI toggle — when False, hides the "Send Fax" header button
|
||||
on sale orders and invoices. Smart "Faxes" button (count
|
||||
badge) is unaffected. -->
|
||||
<record id="config_show_send_fax_button" model="ir.config_parameter">
|
||||
<field name="key">fusion_faxes.show_send_fax_button</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -17,12 +17,26 @@ class AccountMove(models.Model):
|
||||
string='Fax Count',
|
||||
compute='_compute_fax_count',
|
||||
)
|
||||
x_ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show Send Fax Button',
|
||||
compute='_compute_show_send_fax_button',
|
||||
help='Driven by the Settings toggle '
|
||||
'(fusion_faxes.show_send_fax_button).',
|
||||
)
|
||||
|
||||
@api.depends('x_ff_fax_ids')
|
||||
def _compute_fax_count(self):
|
||||
for move in self:
|
||||
move.x_ff_fax_count = len(move.x_ff_fax_ids)
|
||||
|
||||
def _compute_show_send_fax_button(self):
|
||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_faxes.show_send_fax_button', 'True',
|
||||
)
|
||||
show = str(param).lower() not in ('false', '0', '')
|
||||
for move in self:
|
||||
move.x_ff_show_send_fax_button = show
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this invoice."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
string='Enable RingCentral Faxing',
|
||||
config_parameter='fusion_faxes.ringcentral_enabled',
|
||||
)
|
||||
ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show "Send Fax" Button on Sale Orders & Invoices',
|
||||
config_parameter='fusion_faxes.show_send_fax_button',
|
||||
default=True,
|
||||
help='When enabled, the "Send Fax" header button appears on '
|
||||
'sale order and invoice forms (for users in the Fax User '
|
||||
'group). Turn off to hide the button without removing '
|
||||
'fax-user access.',
|
||||
)
|
||||
ff_ringcentral_server_url = fields.Char(
|
||||
string='RingCentral Server URL',
|
||||
config_parameter='fusion_faxes.ringcentral_server_url',
|
||||
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
}
|
||||
|
||||
def set_values(self):
|
||||
"""Protect credential fields from being blanked accidentally."""
|
||||
"""Protect credential fields from being blanked accidentally
|
||||
and force-persist the Send Fax Boolean.
|
||||
|
||||
Odoo's stock ``set_param`` removes the row when a Boolean
|
||||
config_parameter is False, which makes the ``get_param``
|
||||
fallback default kick in — toggling OFF then would silently
|
||||
re-show the button. We bypass that by writing 'True' / 'False'
|
||||
as a string after super() runs so the row always exists.
|
||||
"""
|
||||
protected_keys = [
|
||||
'fusion_faxes.ringcentral_client_id',
|
||||
'fusion_faxes.ringcentral_client_secret',
|
||||
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
|
||||
existing = ICP.get_param(key, '')
|
||||
if existing:
|
||||
ICP.set_param(key, existing)
|
||||
return super().set_values()
|
||||
res = super().set_values()
|
||||
ICP.set_param(
|
||||
'fusion_faxes.show_send_fax_button',
|
||||
'True' if self.ff_show_send_fax_button else 'False',
|
||||
)
|
||||
return res
|
||||
|
||||
@@ -17,12 +17,28 @@ class SaleOrder(models.Model):
|
||||
string='Fax Count',
|
||||
compute='_compute_fax_count',
|
||||
)
|
||||
x_ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show Send Fax Button',
|
||||
compute='_compute_show_send_fax_button',
|
||||
help='Driven by the Settings toggle '
|
||||
'(fusion_faxes.show_send_fax_button). Default True for '
|
||||
'back-compat — the button stays visible until a manager '
|
||||
'turns it off.',
|
||||
)
|
||||
|
||||
@api.depends('x_ff_fax_ids')
|
||||
def _compute_fax_count(self):
|
||||
for order in self:
|
||||
order.x_ff_fax_count = len(order.x_ff_fax_ids)
|
||||
|
||||
def _compute_show_send_fax_button(self):
|
||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_faxes.show_send_fax_button', 'True',
|
||||
)
|
||||
show = str(param).lower() not in ('false', '0', '')
|
||||
for order in self:
|
||||
order.x_ff_show_send_fax_button = show
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this sale order."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"
|
||||
invisible="not x_ff_show_send_fax_button"
|
||||
groups="fusion_faxes.group_fax_user"/>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show "Send Fax" button toggle -->
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ff_show_send_fax_button"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ff_show_send_fax_button"/>
|
||||
<div class="text-muted">
|
||||
Show the "Send Fax" header button on sale orders and invoices.
|
||||
Turn off to hide the button without removing fax-user access.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server URL -->
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
invisible="not ff_ringcentral_enabled">
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"
|
||||
invisible="not x_ff_show_send_fax_button"
|
||||
groups="fusion_faxes.group_fax_user"/>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -29,10 +29,11 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
|
||||
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
|
||||
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
|
||||
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix:** size `paperformat.margin_top` to the actual rendered header height, then drop body `padding-top` to a tiny visual gap (~5mm). 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). Update the right one and don't bleed changes across reports. | `fusion_plating_reports`, `report.paperformat` |
|
||||
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. **For landscape custom-header reports, reuse `paperformat_fp_a4_landscape_compact`** (same shape, just rotated) — invoice landscape uses this; don't create yet-another-landscape-compact. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. (4) **XML comments cannot contain `--` (double-hyphen)** per the XML spec — `<!-- needs wkhtmltopdf --footer-html -->` fails with `XMLSyntaxError: Comment must not contain '--' (double-hyphen)`. Rewrite without the double-hyphen: `<!-- needs a wkhtmltopdf footer-html arg -->`. Bites when documenting CLI flags or option names in QWeb comments. | `fusion_plating_reports`, `report.paperformat` |
|
||||
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
|
||||
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
|
||||
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
|
||||
| **stock.picking.move_ids_without_package removed** | Odoo 19 dropped `move_ids_without_package` on `stock.picking` — `<t t-foreach="doc.move_ids_without_package">` raises `AttributeError: 'stock.picking' object has no attribute 'move_ids_without_package'` at QWeb render. Use `move_ids` instead (all stock moves on the picking). The filtered "without_package" variant no longer exists; if you really need to exclude packaged moves, filter `move_ids` in QWeb (`move_ids.filtered(lambda m: not m.package_level_id)`) or in a python helper. Same gotcha applies to any old report/template ported from Odoo 16/17. | any report/view iterating over picking moves |
|
||||
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
|
||||
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
|
||||
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
|
||||
@@ -43,6 +44,10 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
|
||||
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
|
||||
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
|
||||
|
||||
### Pending — IN PROGRESS when this session ended
|
||||
|
||||
@@ -161,6 +166,20 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
||||
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
||||
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
||||
|
||||
## Removing menus/records — Odoo does NOT auto-delete orphans
|
||||
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
||||
```xml
|
||||
<delete model="ir.ui.menu" id="module_name.menu_xmlid_to_remove"/>
|
||||
```
|
||||
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
|
||||
|
||||
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
|
||||
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
|
||||
```
|
||||
ValueError: Invalid field 'numbercall' in 'ir.cron'
|
||||
```
|
||||
Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval_number`, `interval_type`, `active`. Caught during the 2026-05-22 entech deploy of the auto-pause cron.
|
||||
|
||||
## Critical Rules — Odoo 19
|
||||
1. **NEVER code from memory** — Read reference files from the server first.
|
||||
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.
|
||||
@@ -334,13 +353,57 @@ POST /fp/recipe/duplicate — deep-copy recipe
|
||||
### Client Recipes Created
|
||||
- `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml`
|
||||
|
||||
## Plant Overview Dashboard
|
||||
- OWL client action: `fp_plant_overview` in `fusion_plating_shopfloor`
|
||||
- Kanban columns = work centres, cards = active `mrp.workorder` records
|
||||
- Drag & drop between columns (writes `workcenter_id` on the work order)
|
||||
- Endpoint: `POST /fp/shopfloor/plant_overview`
|
||||
- Move endpoint: `POST /fp/shopfloor/plant_overview/move_card`
|
||||
- Auto-refreshes every 30s
|
||||
## Shop Floor Architecture (2026-05-22 tablet redesign — Phases 1-4)
|
||||
|
||||
Spec: [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md)
|
||||
Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
|
||||
|
||||
**Three OWL client actions** (registered under `registry.category("actions")`):
|
||||
- `fp_shopfloor_landing` — Workstation kanban entry. Station-scoped or All-Plant mode toggle. Tap a card → JobWorkspace. Replaces the legacy `fp_shopfloor_tablet` and folds in `fp_plant_overview`.
|
||||
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
|
||||
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
|
||||
|
||||
**Five shared OWL services** in `fusion_plating_shopfloor/static/src/js/components/`:
|
||||
- `WorkflowChip` — workflow.state pill + optional next-action hint
|
||||
- `GateViz` — "Can't start because…" explainer (reads `fp.job.step.blocker_kind`/`reason`)
|
||||
- `FpSignaturePad` — Dialog canvas signature capture
|
||||
- `FpHoldComposer` — Dialog hold-create form with reason picker + qty + photo
|
||||
- `FpKanbanCard` — standard WO/step card with embedded WorkflowChip + blocker badge
|
||||
|
||||
**Backend endpoints (Phase 1-4):**
|
||||
- Workspace: `/fp/workspace/{load,hold,sign_off,advance_milestone}`
|
||||
- Landing: `/fp/landing/kanban` (mode=station|all_plant)
|
||||
- Manager: `/fp/manager/{overview,funnel,approval_inbox,at_risk}`
|
||||
|
||||
**Auto-pause cron — fixes 411-hour ghost timers:**
|
||||
- `_cron_autopause_stale_steps()` runs every 30 min
|
||||
- Threshold from `ir.config_parameter` **`fp.shopfloor.autopause_threshold_hours`** (default 8.0)
|
||||
- Recipe nodes opt out via `fusion.plating.process.node.long_running=True` for 24h bakes etc.
|
||||
- Flips `state=in_progress` idle > threshold to `paused` with chatter audit
|
||||
|
||||
**Key model fields added (Phase 1-4):**
|
||||
- `fp.job.display_wo_name` — "WO # 00001" formatter for tablet/dashboard (model `name` field stays `WH/JOB/…`; system-wide sequence rename deferred)
|
||||
- `fp.job.late_risk_ratio` — stored Float (remaining_planned / minutes_to_deadline) driving At-Risk view
|
||||
- `fp.job.active_step_id` — computed M2o to currently in_progress step
|
||||
- `fp.job.step.blocker_kind` / `blocker_reason` / `blocker_jump_target_*` — drives GateViz
|
||||
- `fp.work.centre.bottleneck_score` / `avg_wait_minutes` — drives At-Risk bottleneck heatmap
|
||||
|
||||
**Operator ACL lift (per "techs wear multiple hats" rule):**
|
||||
- `fp.certificate` — operator gained write (flip draft → issued from tablet)
|
||||
- `fp.thickness.reading` — operator gained create+write (Fischerscope capture from tablet)
|
||||
- `fp.job.node.override` — operator gained read (see opt-out badges on steps)
|
||||
- Supervisor-only ops (step Skip, hold Release) enforced in `workspace_controller.py`, not ACL
|
||||
|
||||
**Deprecated but still live** (cleanup is Phase 5):
|
||||
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
|
||||
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
|
||||
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new Landing component uses it for drag-and-drop
|
||||
|
||||
**Old patterns to avoid:**
|
||||
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
|
||||
- Don't add SCSS that uses raw hex without an `@if $o-webclient-color-scheme == dark` branch (dark mode breaks otherwise; see existing `_workflow_chip.scss` as the template)
|
||||
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
|
||||
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
# Shop Floor PIN Gate + Auto-Lock — Design Spec
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Status:** Awaiting user review
|
||||
**Phase:** 6 (sequel to Phases 1-5 of 2026-05-22-shopfloor-tablet-redesign)
|
||||
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
|
||||
**Target client:** EN Technologies (Fusion Plating)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Phases 1-5 of the tablet redesign shipped on 2026-05-22 (entech LXC 111). They assume a single user is "logged in" to a tablet for the duration of use. Real shop floors don't work that way:
|
||||
|
||||
- A single tablet sits at the **EN Plating tank** (or de-rack table, masking station, QC bench).
|
||||
- Multiple technicians rotate through the station during a shift.
|
||||
- A tech walks away mid-shift; the next tech walks up — and without a lock, the new tech is operating under the previous tech's identity.
|
||||
- Every step start, finish, scrap, hold, signature, and milestone advance gets attributed to the wrong person.
|
||||
- AS9100 / Nadcap audit trails break. Operators sign off on each other's work without knowing it.
|
||||
|
||||
The fix needs to be **fast** (PIN in < 2 seconds), **familiar** (matches iPad / debit-card UX techs already know), and **silent on the timer side** (locking the tablet must not pause a part in a tank).
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- Each tech identifies themselves with a personal 4-digit PIN.
|
||||
- Tablet auto-locks after a configurable idle period (default 5 min).
|
||||
- Quick-switch UX: tap your face tile → enter PIN → unlocked. No typing usernames.
|
||||
- All shop-floor actions (step start/finish, holds, sign-offs, milestone advances) carry the correct tech identity for audit.
|
||||
- No interruption to in-progress step timers — the server keeps counting.
|
||||
- Manager can reset a forgotten PIN; no SMS/email infrastructure required.
|
||||
- Per-station roster: a tablet at EN Plating only shows techs trained on EN Plating.
|
||||
|
||||
## 3. Non-goals (v1)
|
||||
|
||||
- Multi-factor authentication (TOTP, SMS).
|
||||
- Biometric unlock (Face ID, fingerprint).
|
||||
- NFC badges or RFID readers.
|
||||
- Self-service PIN reset via email/SMS (manager-side only).
|
||||
- "Remember me" cross-device sessions.
|
||||
- Camera-based presence detection / liveness checks.
|
||||
|
||||
## 4. UX
|
||||
|
||||
### 4.1 Lock screen — tile grid
|
||||
|
||||
Rendered first thing when the tablet boots, after any auto-lock, and after the Hand-Off button. Replaces the current Landing/Workspace/Dashboard view entirely.
|
||||
|
||||
- 3-5 tiles per row, sized for touch (~120×140 px each).
|
||||
- Sort: clocked-in techs first, alphabetical within bucket.
|
||||
- Each tile: avatar + name + small green dot if clocked in.
|
||||
- A station with `x_fc_authorised_user_ids` configured shows only those techs; otherwise all techs in the operator group.
|
||||
- "Other..." chip at the end opens a username search for off-roster cases (cross-trained tech covering an unfamiliar station).
|
||||
- Tap a tile → PIN pad slides up as a modal.
|
||||
|
||||
### 4.2 PIN pad
|
||||
|
||||
- Numeric 0-9 in a 3×4 grid + Clear + Submit.
|
||||
- 4 dot placeholders fill as digits are typed.
|
||||
- Auto-submit on the 4th digit (no Enter required).
|
||||
- Wrong PIN → quick shake animation (CSS keyframe), dots clear, tile grid stays.
|
||||
- After 5 sequential failures for one tech, that tech is locked for 5 minutes. Other techs can still unlock the tablet.
|
||||
- "Forgot?" link surfaces a friendly message: "Ask a manager to reset your PIN."
|
||||
|
||||
### 4.3 Hand-Off button
|
||||
|
||||
- Top-right corner of every authenticated view (Landing, Workspace, Manager Dashboard), next to QR scan.
|
||||
- Big icon + label: 🔒 **Hand Off**.
|
||||
- Tap → confirm dialog "Lock this tablet now?" → instant lock to tile grid.
|
||||
- Confirm dialog prevents accidental locks; can be skipped in v2 with rapid double-tap.
|
||||
|
||||
### 4.4 Idle warning
|
||||
|
||||
- At 30 seconds before auto-lock, a yellow pulsing border appears around the entire viewport.
|
||||
- A toast slides in: "Locking in 28s · tap anywhere to stay".
|
||||
- Countdown decrements in 1s ticks until 0.
|
||||
- Any pointer/touch event clears the warning and resets the timer.
|
||||
- At 0s, the tile grid replaces the current view.
|
||||
|
||||
### 4.5 Session continuity (state preservation on lock)
|
||||
|
||||
| State | On lock | On unlock |
|
||||
|---|---|---|
|
||||
| In-progress step timer | Server-side timer keeps running. No pause event fired. | Resumes accurate elapsed time. |
|
||||
| OWL state (scroll, expanded step) | Preserved in memory | Restored |
|
||||
| HoldComposer modal open | Preserved (dialog still mounted under the lock overlay) | Available immediately |
|
||||
| SignaturePad open mid-stroke | **Thrown away.** Signature flow restarts. | Fresh signature required. |
|
||||
| QR scan drawer open | Preserved | Available |
|
||||
| Refresh interval (15s/8s polling) | Paused | Resumed |
|
||||
|
||||
### 4.6 Profile preferences — set / change PIN
|
||||
|
||||
- New "Tablet PIN" group on `res.users` preferences (user-facing form).
|
||||
- Single button: **Set Tablet PIN** or **Change PIN** (label flips depending on whether a hash exists).
|
||||
- Tapping it opens a modal with 3 PIN inputs:
|
||||
- Current PIN (only if a PIN is set; skipped on first-time set)
|
||||
- New PIN
|
||||
- Confirm new PIN
|
||||
- All three use the same `FpPinPad` component as the unlock screen.
|
||||
- Subtext shows "Last changed: 2026-05-22" or "Cleared by manager".
|
||||
|
||||
### 4.7 Manager-side reset
|
||||
|
||||
- New header button on `res.users` form: **Reset Tablet PIN** (visible only to `group_fusion_plating_manager` and above).
|
||||
- Tap → confirm dialog → posts to user's chatter ("Tablet PIN reset by Manager X on 2026-05-22") + clears the hash.
|
||||
- Tech sets a new PIN on next unlock attempt.
|
||||
|
||||
## 5. Backend
|
||||
|
||||
### 5.1 Model fields on `res.users` (in `fusion_plating_shopfloor/models/res_users.py` — new file)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `x_fc_tablet_pin_hash` | Char | Hash (SHA-256 + per-user salt) of the PIN. Stored as `<salt>$<hash>`. `groups='fusion_plating.group_fusion_plating_manager'` — non-manager users cannot even read other users' hash field. |
|
||||
| `x_fc_tablet_pin_set_date` | Datetime | When the current hash was set. NULL if PIN was cleared by manager. |
|
||||
| `x_fc_tablet_pin_failed_count` | Integer (0) | Sequential failed attempts since last success. Resets to 0 on a correct PIN. |
|
||||
| `x_fc_tablet_locked_until` | Datetime | Lockout expiry. NULL when not locked. |
|
||||
|
||||
### 5.2 Extras on `fusion.plating.shopfloor.station`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `x_fc_authorised_user_ids` | Many2many → res.users | If non-empty, the tile grid restricts to these users. Empty = "all operators". |
|
||||
| `x_fc_idle_lock_minutes` | Integer, nullable | Per-station override; null = use global default. |
|
||||
|
||||
### 5.3 `ir.config_parameter` defaults
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `fp.shopfloor.tablet_idle_lock_minutes` | `5` | Global idle threshold |
|
||||
| `fp.shopfloor.tablet_pin_fail_threshold` | `5` | Failures before lockout |
|
||||
| `fp.shopfloor.tablet_pin_fail_lockout_minutes` | `5` | Lockout duration |
|
||||
| `fp.shopfloor.tablet_warn_seconds_before_lock` | `30` | When the yellow border appears |
|
||||
|
||||
### 5.4 HTTP endpoints (`/fp/tablet/*`)
|
||||
|
||||
All `type='jsonrpc'`, `auth='user'`. Auth = the tablet's persistent Odoo session (a "shopfloor service" account or any non-locked user).
|
||||
|
||||
| Endpoint | Body | Returns |
|
||||
|---|---|---|
|
||||
| `POST /fp/tablet/tiles` | `{station_id?}` | `{ok, tiles: [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}`. Respects `station.x_fc_authorised_user_ids`. |
|
||||
| `POST /fp/tablet/unlock` | `{user_id, pin}` | `{ok: true, current_tech_id, current_tech_name}` or `{ok: false, error, locked_until?, attempts_remaining}`. |
|
||||
| `POST /fp/tablet/set_pin` | `{old_pin?, new_pin}` | Caller's PIN only. `old_pin` required if a hash exists. `{ok, error?}`. |
|
||||
| `POST /fp/tablet/reset_pin_for` | `{user_id}` | Manager-only; manager group enforced server-side. Clears target user's hash + posts chatter. |
|
||||
| `POST /fp/tablet/ping` | `{current_tech_id}` | Bumps a server-side "last active" timestamp for forensics. Called on every successful tech action. |
|
||||
|
||||
### 5.5 Hash algorithm
|
||||
|
||||
```python
|
||||
import hashlib, secrets
|
||||
|
||||
def _hash_pin(pin: str, salt: bytes = None) -> str:
|
||||
salt = salt or secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
|
||||
return f"{salt.hex()}${digest.hex()}"
|
||||
|
||||
def _verify_pin(pin: str, stored: str) -> bool:
|
||||
salt_hex, expected_hex = stored.split('$', 1)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
|
||||
return digest.hex() == expected_hex
|
||||
```
|
||||
|
||||
200,000 PBKDF2 iterations gives ~50ms verify time on entech-class hardware — fast enough for tech UX, slow enough to make a brute-force attack expensive even with the database stolen.
|
||||
|
||||
### 5.6 Audit propagation (Phase 6.3)
|
||||
|
||||
All existing shop-floor endpoints that take an action (`/fp/shopfloor/start_wo`, `stop_wo`, `bump_qty_done`, `bump_qty_scrapped`, `log_chemistry`, `log_thickness_reading`, `quality_hold`, `mark_gate`, `start_bake`, `end_bake`, `/fp/workspace/{hold,sign_off,advance_milestone}`) gain an **optional** `tablet_tech_id` kwarg.
|
||||
|
||||
When the OWL component passes `tablet_tech_id`:
|
||||
|
||||
- Server verifies the id corresponds to a recent successful `/fp/tablet/unlock` (within session's idle window).
|
||||
- All chatter posts use that user's name instead of `env.uid`.
|
||||
- All writes to records set `create_uid` / `write_uid` to that user (via `with_user(...)` context manager).
|
||||
- If `tablet_tech_id` is missing or stale, server falls back to `env.uid` (the tablet's session user) for back-compat.
|
||||
|
||||
This keeps the audit trail honest without forcing a full Odoo session swap on every PIN unlock (which would clear all OWL state and JS bundle cache).
|
||||
|
||||
## 6. Frontend architecture
|
||||
|
||||
### 6.1 New OWL components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|---|---|---|
|
||||
| `FpTabletLock` | `static/src/js/tablet_lock.js` | Top-level wrapper around Landing/Workspace/Manager. Renders tile grid when locked; renders children when unlocked. |
|
||||
| `FpPinPad` | `static/src/js/components/pin_pad.js` | Numeric pad modal. Used by FpTabletLock unlock AND Profile set-PIN flow. |
|
||||
| `FpPinSetup` | `static/src/js/components/pin_setup.js` | Modal for set/change PIN. Wraps 3 instances of FpPinPad (old + new + confirm). |
|
||||
| `FpIdleWarning` | `static/src/js/components/idle_warning.js` | Yellow-border + countdown toast component shown at T-30s. |
|
||||
|
||||
### 6.2 Activity tracker service
|
||||
|
||||
- Registered as `fp_shopfloor_activity` in the OWL `services` registry.
|
||||
- Tracks `lastActiveAt` (epoch ms).
|
||||
- Listens at document level: `pointerdown`, `touchstart`, `keydown`, `visibilitychange`.
|
||||
- Public API: `bumpActivity()`, `getSecondsUntilLock()`, `subscribe(cb)`, `lock()`.
|
||||
- Bumps server-side on every `ping` (debounced to once per 30s).
|
||||
|
||||
### 6.3 Auto-lock flow
|
||||
|
||||
```js
|
||||
// inside FpTabletLock setup()
|
||||
this.activity = useService("fp_shopfloor_activity");
|
||||
this._tick = setInterval(() => {
|
||||
const remaining = this.activity.getSecondsUntilLock();
|
||||
if (remaining <= 0) {
|
||||
this.state.locked = true;
|
||||
this.state.currentTechId = null;
|
||||
} else if (remaining <= this.warnThresholdSec) {
|
||||
this.state.idleWarning = remaining;
|
||||
} else if (this.state.idleWarning) {
|
||||
this.state.idleWarning = null; // user tapped, reset
|
||||
}
|
||||
}, 1000);
|
||||
```
|
||||
|
||||
### 6.4 RPC plumbing
|
||||
|
||||
A tiny client-side helper wraps `rpc()` so every shop-floor call automatically includes `tablet_tech_id`:
|
||||
|
||||
```js
|
||||
// fp_rpc.js
|
||||
import { rpc as baseRpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const techStore = registry.category("services").get("fp_shopfloor_tech_store");
|
||||
|
||||
export function fpRpc(url, params = {}) {
|
||||
if (techStore.currentTechId) {
|
||||
params = { ...params, tablet_tech_id: techStore.currentTechId };
|
||||
}
|
||||
return baseRpc(url, params);
|
||||
}
|
||||
```
|
||||
|
||||
Landing, Workspace, Manager Dashboard switch from `rpc(...)` to `fpRpc(...)` for action calls. Read-only calls (load, tiles, kanban) don't need the kwarg.
|
||||
|
||||
### 6.5 Component composition
|
||||
|
||||
```
|
||||
FpTabletLock (NEW outer wrapper, mounted by every client action)
|
||||
├── if locked → FpPinPad (tile grid + entry)
|
||||
├── if idle warning → FpIdleWarning overlay
|
||||
└── else → existing client action (Landing | Workspace | Manager Dashboard)
|
||||
+ Hand-Off button injected into existing headers
|
||||
```
|
||||
|
||||
The "tablet locked" boolean lives in a shared OWL service (`fp_shopfloor_tech_store`) — every client action checks it on mount and subscribes for changes.
|
||||
|
||||
## 7. Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| No tech has set a PIN yet | Tile shows "PIN required" overlay. Tap tile → guided "you must set a PIN before using this tablet" → set-PIN flow → unlock. |
|
||||
| Manager just reset a tech's PIN | Tile still shows; tap → "PIN was cleared by a manager — set a new one" → set-PIN flow → unlock. |
|
||||
| Tablet boots with no station paired | Tile grid shows + a "Pair this station" CTA. Station QR scan works before any tech is logged in. |
|
||||
| Network drop mid-unlock | Spinner + Retry button after 5s. Backend tolerates duplicate unlocks (idempotent on success — counter just stays at 0). |
|
||||
| Tech mid-step when tablet locks | Step timer keeps running on server. Auto-pause cron (Phase 2) is the upper-bound safety net. |
|
||||
| Tech A's PIN locked for 5 min — can tech B unlock? | Yes. Lockout is per-user, not per-tablet. |
|
||||
| Tech keeps tablet active by setting a heavy weight on it | Activity = pointer/touch/key events only, not mouse-move. A weight doesn't fire those events. Still locks after 5 min. |
|
||||
| Tech is mid-RPC when lock fires | RPC completes (server keeps running). Response is dropped silently — UI is already showing the tile grid. |
|
||||
| Two tabs / windows on the same browser | Each tab has its own FpTabletLock state. They lock independently. Acceptable for v1; not a real shop scenario. |
|
||||
| Manager wants to act AS a tech | Out of scope. Manager unlocks with their own PIN; their actions carry their own uid. |
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Python tests (`fusion_plating_shopfloor/tests/test_tablet_pin.py`)
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_set_pin_first_time` | User with no hash can set PIN; resulting hash is salted and length > 32. |
|
||||
| `test_set_pin_change_requires_old` | Setting a new PIN when one exists requires correct old_pin; wrong old_pin rejected. |
|
||||
| `test_unlock_correct_pin_resets_failure_count` | Failed → failed → correct → counter is 0. |
|
||||
| `test_unlock_5_wrong_locks_user` | 5 wrong attempts → 6th returns `locked_until`. 7th still rejected. |
|
||||
| `test_lockout_expires_after_threshold` | After 5 min sim time elapsed → next attempt allowed again. |
|
||||
| `test_reset_pin_for_requires_manager` | Operator → AccessError. Supervisor → AccessError. Manager → success. |
|
||||
| `test_reset_pin_clears_hash_and_posts_chatter` | After reset: hash is False, set_date is False, chatter has "PIN reset by Manager X". |
|
||||
| `test_tiles_filtered_by_station_roster` | Station with authorised_user_ids → tiles is subset. Empty list → all operator-group users. |
|
||||
| `test_audit_kwarg_used_in_step_finish` | RPC with `tablet_tech_id=N` → step's `write_uid == N` (not env.uid). |
|
||||
| `test_audit_kwarg_invalid_falls_back_to_session` | Invalid `tablet_tech_id` → write_uid == env.uid, no error. |
|
||||
|
||||
### 8.2 Manual QA
|
||||
|
||||
`docs/qa/2026-05-22-shopfloor-pin-gate-qa.md` walkthrough:
|
||||
|
||||
1. Tech A sets PIN via Preferences
|
||||
2. Tech A unlocks tablet → starts a step
|
||||
3. 5 min idle elapses → tablet locks
|
||||
4. Tech B unlocks → finishes Tech A's step
|
||||
5. Audit chatter shows: started by A at T+0, finished by B at T+6
|
||||
6. Manager taps Reset PIN on Tech A's res.users form
|
||||
7. Tech A unlocks → set-PIN flow
|
||||
8. Tech A fails PIN 5 times → lockout kicks in
|
||||
9. Tech A waits 5 min → unlocks successfully
|
||||
|
||||
## 9. Build sequence (3 sub-phases)
|
||||
|
||||
Each ships independently and can be rolled back independently.
|
||||
|
||||
| Sub-phase | Ships | Independently deployable? |
|
||||
|---|---|---|
|
||||
| **6.1 — Backend** | model fields on res.users + station extras + ir.config_parameter defaults + 5 `/fp/tablet/*` endpoints + Profile prefs Set/Change PIN button + Manager Reset PIN button on res.users form | Yes — works silently behind the scenes. Techs can set PINs but the lock screen doesn't render yet. |
|
||||
| **6.2 — Frontend lock screen** | FpTabletLock wrapper + FpPinPad + FpIdleWarning + activity tracker service + Hand-Off button injection into existing headers | Yes — lock screen goes live. Audit credit still defaults to tablet session user without 6.3. |
|
||||
| **6.3 — Audit propagation** | `tablet_tech_id` optional kwarg on all existing action endpoints + `fpRpc()` wrapper + Landing/Workspace/Manager updated to use it | Yes — refines the audit trail. Without it, actions are recorded against the tablet's session uid. |
|
||||
|
||||
## 10. Backwards compatibility
|
||||
|
||||
- Any tablet that hasn't been upgraded to Phase 6.2 continues to work unauthenticated (no lock screen). Once 6.2 lands, ALL tablets start showing the lock screen.
|
||||
- Endpoints from Phases 1-5 keep their existing signatures. `tablet_tech_id` is purely additive.
|
||||
- Setting / changing the PIN is opt-in per user. A tech without a PIN sees a "set one to continue" prompt; they can't dismiss it.
|
||||
- No model migration required — all new fields default to NULL.
|
||||
- `ir.config_parameter` defaults are read at runtime, no install-time setup needed.
|
||||
|
||||
## 11. Rollback strategy
|
||||
|
||||
| Sub-phase | Rollback |
|
||||
|---|---|
|
||||
| 6.1 | Disable endpoints in `controllers/__init__.py`. Model fields are additive, safe to drop. |
|
||||
| 6.2 | Hide `FpTabletLock` via a feature flag (`ir.config_parameter` `fp.shopfloor.tablet_lock_enabled`, default true; set false to bypass). Existing client actions render directly again. |
|
||||
| 6.3 | Stop sending `tablet_tech_id` from `fpRpc()` — server falls back to `env.uid`. |
|
||||
|
||||
## 12. Out of scope for v1
|
||||
|
||||
- Biometric (Face ID, fingerprint)
|
||||
- NFC badges
|
||||
- TOTP / SMS / email-based reset
|
||||
- "Remember me" cross-device sessions
|
||||
- Per-tech idle threshold (only per-station + global)
|
||||
- Lock-screen widgets (weather, time, KPIs) — keep the tile grid focused
|
||||
- Camera-based presence / liveness
|
||||
- Pre-fetched tile grid (each unlock call fetches fresh)
|
||||
- Different PIN lengths per tech (4 digits for everyone)
|
||||
|
||||
## 13. Decisions log
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| 4-digit PIN over 6-digit | Speed. Industry norm. Lockout + per-user-fail-counter makes 10,000 combos secure enough. |
|
||||
| PBKDF2-SHA256, 200k iterations | ~50ms verify on entech hardware. Safe against rainbow tables; brute-force-resistant even with DB stolen. |
|
||||
| Hash field is manager-readable only | Operators can't even view other users' hash. Reduces lateral attack surface. |
|
||||
| Per-user lockout, not per-tablet | A bad-actor wrong-PIN'ing one user shouldn't deny service to other techs on the same tablet. |
|
||||
| 5 minute idle default | Compromise: long enough for legitimate idle-watching of a tank, short enough that a walk-away is caught. Configurable per-station. |
|
||||
| Server-side step timer keeps running on lock | Locking is UI; nothing should pause physical processes. Auto-pause cron is the deeper safety net. |
|
||||
| Single Odoo session, PIN overlay credits via `tablet_tech_id` kwarg | No JS bundle reload, no state loss, no flicker. Audit kwarg keeps the trail honest. |
|
||||
| Manager-only reset (no self-service) | Plating shops rarely have per-tech email/SMS. Manager is always present. Lower infra. |
|
||||
| 30s warning before lock | Compromise: catches "I was right there" cases without being annoyingly chatty. |
|
||||
| `tablet_tech_id` is opt-in additive kwarg | Lets 6.3 ship after 6.2 without breaking anything; lets older callers continue working unchanged. |
|
||||
|
||||
## 14. Future v2 candidates
|
||||
|
||||
- NFC badge tap (cheap USB readers, ~$30)
|
||||
- Personal QR badge on lanyard (no hardware beyond what we already have)
|
||||
- Per-tech idle threshold (long-shift senior techs vs cross-trained probationers)
|
||||
- Lock-screen KPIs (shop output today, hot WOs visible without unlocking)
|
||||
- "Switch tech without re-PIN" — keep both signed in for hand-off audit on the same step
|
||||
- Mobile app companion with biometric unlock
|
||||
|
||||
---
|
||||
|
||||
**Next step:** user reviews this spec. Once approved, transition to `superpowers:writing-plans` to produce the phased implementation plan.
|
||||
@@ -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',
|
||||
'version': '19.0.20.6.2',
|
||||
'version': '19.0.20.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
|
||||
'progress (e.g. paperwork or QA review that runs alongside '
|
||||
'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(
|
||||
[
|
||||
('disabled', 'Required'),
|
||||
|
||||
@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
|
||||
# field via _inherit if/when the bake-oven coupling is needed.
|
||||
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 = [
|
||||
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
|
||||
]
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
<field name="parallel_start"
|
||||
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."/>
|
||||
<!-- 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"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.7.8.0',
|
||||
'version': '19.0.7.9.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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_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_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
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.5.1',
|
||||
'version': '19.0.21.7.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -56,10 +56,12 @@ Provides:
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'views/fp_so_job_sort_views.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
||||
@@ -72,6 +74,13 @@ Provides:
|
||||
'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
|
||||
],
|
||||
# Register the Job Status pill SCSS in both bundles so the
|
||||
# `@if $o-webclient-color-scheme == dark` branch compiles for
|
||||
# the dark variant (see CLAUDE.md "Dark Mode" — Odoo 19 has no
|
||||
# runtime DOM toggle, two pre-built bundles).
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import fp_part_catalog
|
||||
from . import fp_pricing_complexity_surcharge
|
||||
from . import fp_pricing_rule
|
||||
from . import fp_sale_description_template
|
||||
from . import fp_so_job_sort
|
||||
from . import fp_quote_configurator
|
||||
from . import fp_serial
|
||||
from . import sale_order
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpSoJobSort(models.Model):
|
||||
"""A user-defined grouping bucket for sale orders ("Job Sorting").
|
||||
|
||||
Same pattern as `fusion.plating.tank.section` — every shop slices its
|
||||
SO backlog differently (by customer programme, by priority, by
|
||||
fabricator group, by week, etc.). Sections are free-form, renameable,
|
||||
quick-creatable from the M2O dropdown, and let users group the SO
|
||||
list with fold/expand sections.
|
||||
"""
|
||||
_name = 'fp.so.job.sort'
|
||||
_description = 'Fusion Plating — Sale Order Job Sort'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Job Sorting',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
color = fields.Integer(string='Color', default=0)
|
||||
fold = fields.Boolean(
|
||||
string='Folded by Default',
|
||||
help='When set, this section appears collapsed in the grouped '
|
||||
'SO list so the body rows are hidden until expanded.',
|
||||
)
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
sale_order_ids = fields.One2many(
|
||||
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
|
||||
)
|
||||
sale_order_count = fields.Integer(
|
||||
compute='_compute_sale_order_count',
|
||||
)
|
||||
|
||||
@api.depends('sale_order_ids')
|
||||
def _compute_sale_order_count(self):
|
||||
for rec in self:
|
||||
rec.sale_order_count = len(rec.sale_order_ids)
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_job_sort_id', '=', self.id)],
|
||||
'context': {'default_x_fc_job_sort_id': self.id},
|
||||
}
|
||||
@@ -67,6 +67,28 @@ class SaleOrder(models.Model):
|
||||
'Net Terms strategies.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
||||
|
||||
# Lead Time (Phase D11) — promised production window in business
|
||||
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
|
||||
# so we render a proper expectation on the SO confirmation instead
|
||||
# of the binary Standard/Rush we had before. Both fields default to
|
||||
# 0 — `x_fc_lead_time_display` computes the right human-readable
|
||||
# string (range / single value / Rush / Standard) for the PDF.
|
||||
x_fc_lead_time_min_days = fields.Integer(
|
||||
string='Lead Time Min (days)', tracking=True,
|
||||
help='Lower bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_max_days = fields.Integer(
|
||||
string='Lead Time Max (days)', tracking=True,
|
||||
help='Upper bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_display = fields.Char(
|
||||
string='Lead Time',
|
||||
compute='_compute_lead_time_display',
|
||||
help='Human-readable lead time string for the SO confirmation PDF.',
|
||||
)
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
@@ -88,6 +110,16 @@ class SaleOrder(models.Model):
|
||||
help="Customer's internal job number for cross-referencing.",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
help='Free-form bucket that groups this SO in the "Sale Orders '
|
||||
'by Sorting" list view. Quick-create from the dropdown — '
|
||||
'each shop slices its backlog differently (customer programme, '
|
||||
'priority, week, etc.).',
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
string='Planned Start Date', tracking=True,
|
||||
)
|
||||
@@ -129,6 +161,16 @@ class SaleOrder(models.Model):
|
||||
string='Deadline',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
# Drives the colour of the Deadline column. Computed in the same pass
|
||||
# as x_fc_deadline_countdown so the buckets always agree with the
|
||||
# human-readable countdown string.
|
||||
x_fc_deadline_urgency = fields.Selection(
|
||||
[('overdue', 'Overdue'),
|
||||
('urgent', 'Due within 2 days'),
|
||||
('safe', 'More than 2 days')],
|
||||
string='Deadline Urgency',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
x_fc_order_completion_date = fields.Date(
|
||||
string='Order Completion Date',
|
||||
compute='_compute_order_completion_date',
|
||||
@@ -241,6 +283,157 @@ class SaleOrder(models.Model):
|
||||
compute='_compute_invoiced_amount',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
# Single "Job Status" pill rendered in the SO list. Pipeline order:
|
||||
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
|
||||
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
|
||||
# Delivered → Invoiced → Paid → Cancelled.
|
||||
# Rendered as an Html field so each kind can carry its own tint via
|
||||
# an .fp-kind-* class — Bootstrap's 5 decoration-* slots aren't
|
||||
# enough to give every phase a distinct colour. SCSS bundle at
|
||||
# static/src/scss/fp_job_status_pill.scss owns the colour map.
|
||||
x_fc_fp_job_status = fields.Html(
|
||||
string='Job Status',
|
||||
compute='_compute_fp_job_status',
|
||||
sanitize=False,
|
||||
help='Single at-a-glance pill that advances through the order '
|
||||
'lifecycle: receiving → WO progress → shipping → invoicing.',
|
||||
)
|
||||
x_fc_fp_job_status_kind = fields.Selection(
|
||||
[('muted', 'Draft (grey)'),
|
||||
('warning', 'Awaiting / Partial (amber)'),
|
||||
('primary', 'Ready / Milestone (purple)'),
|
||||
('info', 'Active Work (blue)'),
|
||||
('shipping', 'Shipping (cyan)'),
|
||||
('delivered', 'Delivered (teal)'),
|
||||
('invoiced', 'Invoiced (lime)'),
|
||||
('paid', 'Paid (green bold)'),
|
||||
('danger', 'Cancelled (red)')],
|
||||
string='Job Status Kind',
|
||||
compute='_compute_fp_job_status',
|
||||
help='Colour category that backs the Job Status pill — also '
|
||||
'usable for filtering / grouping in the list search panel.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'x_fc_receiving_status',
|
||||
'x_fc_wo_completion',
|
||||
'invoice_ids.state',
|
||||
'invoice_ids.payment_state',
|
||||
'invoice_ids.move_type',
|
||||
)
|
||||
def _compute_fp_job_status(self):
|
||||
from markupsafe import Markup as _Markup
|
||||
from markupsafe import escape as _escape
|
||||
for so in self:
|
||||
label, kind = self._fp_resolve_job_status(so)
|
||||
so.x_fc_fp_job_status_kind = kind
|
||||
so.x_fc_fp_job_status = _Markup(
|
||||
'<span class="fp-job-status fp-kind-%s">%s</span>'
|
||||
) % (_Markup(kind), _escape(label))
|
||||
|
||||
@staticmethod
|
||||
def _fp_resolve_job_status(so):
|
||||
# Terminal SO states first.
|
||||
if so.state == 'cancel':
|
||||
return ('Cancelled', 'danger')
|
||||
if so.state in ('draft', 'sent'):
|
||||
return ('Draft', 'muted')
|
||||
|
||||
# Invoice phase (terminal positive states).
|
||||
posted = so.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted'
|
||||
and m.move_type in ('out_invoice', 'out_refund')
|
||||
)
|
||||
if posted and all(
|
||||
m.payment_state in ('paid', 'in_payment') for m in posted
|
||||
):
|
||||
return ('Paid', 'paid')
|
||||
|
||||
# Shipping phase signals — read once.
|
||||
ship_status = None
|
||||
if 'x_fc_receiving_ids' in so._fields:
|
||||
for r in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
r.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in r._fields else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
# Latch the most-advanced status across all receivings.
|
||||
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
|
||||
cur = (
|
||||
'delivered' if ship.status == 'delivered'
|
||||
else 'in_transit' if ship.status == 'shipped'
|
||||
else 'booked' if ship.status in ('confirmed', 'draft')
|
||||
else None
|
||||
)
|
||||
if rank[cur] > rank[ship_status]:
|
||||
ship_status = cur
|
||||
|
||||
if posted and ship_status == 'delivered':
|
||||
return ('Invoiced', 'invoiced')
|
||||
if ship_status == 'delivered':
|
||||
return ('Delivered', 'delivered')
|
||||
if ship_status == 'in_transit':
|
||||
return ('In Transit', 'shipping')
|
||||
|
||||
# WO phase — figure out total steps and the current step name.
|
||||
tot = 0
|
||||
current_step_name = None
|
||||
Job = so.env.get('fp.job')
|
||||
if Job is not None and so.name:
|
||||
jobs = Job.sudo().search([('origin', '=', so.name)])
|
||||
if jobs:
|
||||
steps = jobs.mapped('step_ids').sorted(
|
||||
lambda s: (s.job_id.id, s.sequence)
|
||||
)
|
||||
tot = len(steps)
|
||||
# Priority: in_progress → paused → next ready/pending.
|
||||
current = (
|
||||
steps.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
or steps.filtered(lambda s: s.state == 'paused')[:1]
|
||||
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
|
||||
)
|
||||
current_step_name = current.name if current else None
|
||||
|
||||
all_steps_done = tot > 0 and current_step_name is None
|
||||
|
||||
if all_steps_done:
|
||||
if ship_status == 'booked':
|
||||
return ('Ship Booked', 'shipping')
|
||||
return ('Ready to Ship', 'primary')
|
||||
if current_step_name:
|
||||
return (current_step_name, 'info')
|
||||
|
||||
# Receiving phase (no WO yet).
|
||||
recv = so.x_fc_receiving_status or 'not_received'
|
||||
if recv == 'received':
|
||||
return ('Ready to Start', 'primary')
|
||||
if recv == 'partial':
|
||||
return ('Parts Partial', 'warning')
|
||||
return ('Awaiting Parts', 'warning')
|
||||
|
||||
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
|
||||
def _compute_lead_time_display(self):
|
||||
"""Render the lead time as a human-readable string for reports.
|
||||
|
||||
Priority order:
|
||||
- Real min/max range set → "X-Y days" or "X days"
|
||||
- Range not set, rush_order on → "Rush"
|
||||
- Otherwise → "Standard"
|
||||
"""
|
||||
for so in self:
|
||||
mn = so.x_fc_lead_time_min_days or 0
|
||||
mx = so.x_fc_lead_time_max_days or 0
|
||||
if mn and mx and mn != mx:
|
||||
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
|
||||
elif mx or mn:
|
||||
so.x_fc_lead_time_display = '%d days' % (mx or mn)
|
||||
elif so.x_fc_rush_order:
|
||||
so.x_fc_lead_time_display = 'Rush'
|
||||
else:
|
||||
so.x_fc_lead_time_display = 'Standard'
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_wo_completion(self):
|
||||
@@ -493,9 +686,11 @@ class SaleOrder(models.Model):
|
||||
def _compute_deadline_countdown(self):
|
||||
from datetime import datetime
|
||||
now = fields.Datetime.now()
|
||||
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||
for rec in self:
|
||||
if not rec.commitment_date:
|
||||
rec.x_fc_deadline_countdown = False
|
||||
rec.x_fc_deadline_urgency = False
|
||||
continue
|
||||
target = rec.commitment_date
|
||||
if isinstance(target, datetime):
|
||||
@@ -506,12 +701,13 @@ class SaleOrder(models.Model):
|
||||
secs = int(delta.total_seconds())
|
||||
if secs == 0:
|
||||
rec.x_fc_deadline_countdown = 'due now'
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
continue
|
||||
past = secs < 0
|
||||
secs = abs(secs)
|
||||
days = secs // 86400
|
||||
hours = (secs % 86400) // 3600
|
||||
mins = (secs % 3600) // 60
|
||||
abs_secs = abs(secs)
|
||||
days = abs_secs // 86400
|
||||
hours = (abs_secs % 86400) // 3600
|
||||
mins = (abs_secs % 3600) // 60
|
||||
bits = []
|
||||
if days:
|
||||
bits.append('%dd' % days)
|
||||
@@ -523,6 +719,12 @@ class SaleOrder(models.Model):
|
||||
rec.x_fc_deadline_countdown = (
|
||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||
)
|
||||
if past:
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
elif secs <= TWO_DAYS:
|
||||
rec.x_fc_deadline_urgency = 'urgent'
|
||||
else:
|
||||
rec.x_fc_deadline_urgency = 'safe'
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_effective_part_deadline',
|
||||
|
||||
@@ -42,3 +42,5 @@ access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part
|
||||
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
|
||||
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,68 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Status pill on the SO list
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// One pill per row, one colour per phase, vibrant + saturated so phases
|
||||
// pop at a glance against both the light and dark Odoo bundles. Same
|
||||
// hue map for both modes — saturated 500-level Tailwind hues with white
|
||||
// text give consistent contrast against either page background.
|
||||
// =============================================================================
|
||||
|
||||
// ----- Vibrant tints (light + dark) -----
|
||||
$_fp-muted-bg : #6b7280; // slate
|
||||
$_fp-warning-bg : #f59e0b; // amber
|
||||
$_fp-primary-bg : #8b5cf6; // violet
|
||||
$_fp-info-bg : #3b82f6; // blue
|
||||
$_fp-shipping-bg : #06b6d4; // cyan
|
||||
$_fp-delivered-bg : #14b8a6; // teal
|
||||
$_fp-invoiced-bg : #84cc16; // lime
|
||||
$_fp-paid-bg : #16a34a; // green
|
||||
$_fp-danger-bg : #ef4444; // red
|
||||
|
||||
// Matching glow shadows — darker tone of the same hue for a subtle
|
||||
// drop-shadow that gives the pill a "lifted" feel without being noisy.
|
||||
$_fp-muted-glow : rgba(31, 41, 55, 0.35);
|
||||
$_fp-warning-glow : rgba(180, 83, 9, 0.45);
|
||||
$_fp-primary-glow : rgba(91, 33, 182, 0.45);
|
||||
$_fp-info-glow : rgba(29, 78, 216, 0.45);
|
||||
$_fp-shipping-glow : rgba(14, 116, 144, 0.45);
|
||||
$_fp-delivered-glow : rgba(15, 118, 110, 0.45);
|
||||
$_fp-invoiced-glow : rgba(101, 163, 13, 0.45);
|
||||
$_fp-paid-glow : rgba(21, 128, 61, 0.5);
|
||||
$_fp-danger-glow : rgba(185, 28, 28, 0.45);
|
||||
|
||||
// =============================================================================
|
||||
// Pill base
|
||||
// =============================================================================
|
||||
.fp-job-status {
|
||||
display: inline-block;
|
||||
padding: 0.4em 0.95em;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.015em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
min-width: 72px;
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-kind tints — same map applies to light + dark bundles. White text
|
||||
// gives consistent contrast against any saturated mid-tone hue.
|
||||
// =============================================================================
|
||||
.fp-kind-muted { background-color: $_fp-muted-bg; box-shadow: 0 1px 3px $_fp-muted-glow; }
|
||||
.fp-kind-warning { background-color: $_fp-warning-bg; box-shadow: 0 1px 3px $_fp-warning-glow; }
|
||||
.fp-kind-primary { background-color: $_fp-primary-bg; box-shadow: 0 1px 3px $_fp-primary-glow; }
|
||||
.fp-kind-info { background-color: $_fp-info-bg; box-shadow: 0 1px 3px $_fp-info-glow; }
|
||||
.fp-kind-shipping { background-color: $_fp-shipping-bg; box-shadow: 0 1px 3px $_fp-shipping-glow; }
|
||||
.fp-kind-delivered { background-color: $_fp-delivered-bg; box-shadow: 0 1px 3px $_fp-delivered-glow; }
|
||||
.fp-kind-invoiced { background-color: $_fp-invoiced-bg; box-shadow: 0 1px 3px $_fp-invoiced-glow; }
|
||||
.fp-kind-paid {
|
||||
background-color: $_fp-paid-bg;
|
||||
box-shadow: 0 1px 4px $_fp-paid-glow;
|
||||
font-weight: 700;
|
||||
}
|
||||
.fp-kind-danger { background-color: $_fp-danger-bg; box-shadow: 0 1px 3px $_fp-danger-glow; }
|
||||
@@ -0,0 +1,261 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Job Sorting:
|
||||
- Section model views (list/form) under Configuration → Sales.
|
||||
- Alternate SO list ("Sale Orders by Sorting") grouped by job sort
|
||||
with foldable sections and create-from-here support.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== Section management (Configuration) ===== -->
|
||||
<record id="view_fp_so_job_sort_list" model="ir.ui.view">
|
||||
<field name="name">fp.so.job.sort.list</field>
|
||||
<field name="model">fp.so.job.sort</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Job Sorting" editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
<field name="fold" widget="boolean_toggle"/>
|
||||
<field name="sale_order_count"/>
|
||||
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_so_job_sort_form" model="ir.ui.view">
|
||||
<field name="name">fp.so.job.sort.form</field>
|
||||
<field name="model">fp.so.job.sort</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Job Sorting">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_orders" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart">
|
||||
<field name="sale_order_count" widget="statinfo"
|
||||
string="Sale Orders"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Rush Orders"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
<field name="fold"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description"
|
||||
placeholder="What kinds of orders belong in this section?"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_so_job_sort" model="ir.actions.act_window">
|
||||
<field name="name">Job Sorting</field>
|
||||
<field name="res_model">fp.so.job.sort</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_so_job_sort"
|
||||
name="Job Sorting"
|
||||
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||
action="action_fp_so_job_sort"
|
||||
sequence="25"/>
|
||||
|
||||
<!-- ===== Kanban grouped by Job Sorting =====
|
||||
Groups SOs into foldable columns by x_fc_job_sort_id.
|
||||
Drag-drop between columns rewrites the bucket; quick-create on
|
||||
the column header creates a new fp.so.job.sort row. Wired into
|
||||
the existing Sale Orders action below so it shows up in the
|
||||
view-switcher next to the flat list. -->
|
||||
<record id="view_sale_order_kanban_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.kanban.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="x_fc_job_sort_id"
|
||||
group_create="true"
|
||||
group_edit="true"
|
||||
group_delete="true"
|
||||
quick_create="false"
|
||||
sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount_total"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="x_fc_part_numbers_summary"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_deadline_countdown"/>
|
||||
<field name="x_fc_deadline_urgency"/>
|
||||
<field name="x_fc_fp_job_status"/>
|
||||
<field name="state"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="o_kanban_card_content p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<strong><field name="name"/></strong>
|
||||
<span t-att-class="'badge ' + (
|
||||
record.x_fc_deadline_urgency.raw_value == 'overdue' and 'text-bg-danger' or
|
||||
record.x_fc_deadline_urgency.raw_value == 'urgent' and 'text-bg-warning' or
|
||||
record.x_fc_deadline_urgency.raw_value == 'safe' and 'text-bg-success' or
|
||||
'text-bg-light')"
|
||||
t-if="record.x_fc_deadline_countdown.raw_value">
|
||||
<field name="x_fc_deadline_countdown"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted small mb-1">
|
||||
<field name="partner_id"/>
|
||||
</div>
|
||||
<div class="small mb-1" t-if="record.x_fc_part_numbers_summary.raw_value">
|
||||
<i class="fa fa-cube me-1"/>
|
||||
<field name="x_fc_part_numbers_summary"/>
|
||||
</div>
|
||||
<div class="small mb-2" t-if="record.x_fc_customer_job_number.raw_value">
|
||||
<i class="fa fa-hashtag me-1"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<field name="x_fc_fp_job_status" widget="html"/>
|
||||
<strong>
|
||||
<field name="amount_total" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Sale Orders by Sorting (alternate SO list) ===== -->
|
||||
<!-- Duplicate of view_sale_order_list_fp but renamed and intended
|
||||
to be opened with group_by=x_fc_job_sort_id by default so the
|
||||
user sees foldable sections per Job Sorting bucket. -->
|
||||
<record id="view_sale_order_list_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.list.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="priority">99</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sale Orders by Sorting" create="0"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-muted="state == 'cancel'"
|
||||
decoration-danger="x_fc_is_late_forecast">
|
||||
<header>
|
||||
<button name="%(action_fp_direct_order_wizard)d"
|
||||
type="action"
|
||||
string="New Order"
|
||||
class="btn-primary"
|
||||
display="always"/>
|
||||
</header>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="show"/>
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_job_sort_id" optional="show"
|
||||
options="{'no_create_edit': False, 'no_open': True}"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline"
|
||||
optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion"
|
||||
optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"
|
||||
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
<field name="x_fc_part_numbers_summary" string="Part"
|
||||
optional="show"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_invoiced_amount" sum="Invoiced"
|
||||
optional="hide"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="x_fc_fp_job_status" widget="html"
|
||||
string="Job Status" optional="show" readonly="1"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
optional="hide"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-info="x_fc_receiving_status == 'partial'"
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search view for the alternate list: surface "Group by Job
|
||||
Sorting" as a search-default filter. -->
|
||||
<record id="view_sale_order_search_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.search.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Sale Orders by Sorting">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="x_fc_part_numbers_summary" string="Part"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="x_fc_job_sort_id"/>
|
||||
<filter name="late_forecast" string="Late Forecast"
|
||||
domain="[('x_fc_is_late_forecast','=',True)]"/>
|
||||
<filter name="cancelled" string="Cancelled"
|
||||
domain="[('state','=','cancel')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_by_job_sort"
|
||||
string="Job Sorting"
|
||||
context="{'group_by': 'x_fc_job_sort_id'}"/>
|
||||
<filter name="group_by_customer"
|
||||
string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
<filter name="group_by_state"
|
||||
string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Append the kanban view to the existing Sale Orders action so
|
||||
users can switch from the flat list to the grouped-by-sorting
|
||||
kanban (foldable columns, drag-drop bucket reassignment) via
|
||||
the view-switcher icon in the top-right of the SO list. -->
|
||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_sale_order_kanban_fp_by_sorting')})]"/>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_sale_orders_by_sorting" model="ir.actions.act_window">
|
||||
<field name="name">Sale Orders (by Sorting)</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="view_sale_order_list_fp_by_sorting"/>
|
||||
<field name="search_view_id" ref="view_sale_order_search_fp_by_sorting"/>
|
||||
<field name="domain">[('state', 'not in', ('draft', 'sent'))]</field>
|
||||
<field name="context">{'search_default_group_by_job_sort': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_sale_orders_by_sorting"
|
||||
name="Sale Orders (by Sorting)"
|
||||
parent="fusion_plating_configurator.menu_fp_sales"
|
||||
action="action_fp_sale_orders_by_sorting"
|
||||
sequence="12"/>
|
||||
|
||||
</odoo>
|
||||
@@ -111,6 +111,27 @@
|
||||
string="Job Groups"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<!-- Surface Delivery Date (commitment_date) right after Order
|
||||
Date in the header info group. The standard view only
|
||||
shows it buried under the Delivery section in the Other
|
||||
Info tab — having it at the top keeps it visible without
|
||||
scrolling, since most operators are setting it on every
|
||||
order. The same field is also surfaced lower in our
|
||||
Plating tab Scheduling group as "Customer Deadline"; both
|
||||
reference the same field so edits sync. -->
|
||||
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="before">
|
||||
<field name="commitment_date" string="Delivery Date"
|
||||
readonly="state in ('cancel',)"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Job Sorting sits right under Payment Terms — a free-form
|
||||
bucket that groups the SO in the "Sale Orders by Sorting"
|
||||
list. Quick-create from the dropdown. -->
|
||||
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="after">
|
||||
<field name="x_fc_job_sort_id"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
<!-- Multi-part summary: read-only list of every order line
|
||||
@@ -144,11 +165,9 @@
|
||||
string="Job #"/>
|
||||
</list>
|
||||
</field>
|
||||
<!-- Row 1: RFQ/PO (left) + Scheduling (right) — pairs the two
|
||||
tallest groups so neither column dangles empty. -->
|
||||
<group>
|
||||
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
|
||||
<field name="x_fc_configurator_id" readonly="1"/>
|
||||
<field name="x_fc_process_summary" readonly="1"/>
|
||||
</group>
|
||||
<group string="RFQ / PO">
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="upload_rfq_file"
|
||||
@@ -174,29 +193,6 @@
|
||||
<field name="x_fc_po_override_reason"
|
||||
invisible="not x_fc_po_override"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Invoicing">
|
||||
<field name="x_fc_invoice_strategy"/>
|
||||
<field name="x_fc_deposit_percent"
|
||||
invisible="x_fc_invoice_strategy != 'deposit'"/>
|
||||
<field name="x_fc_progress_initial_percent"
|
||||
invisible="x_fc_invoice_strategy != 'progress'"/>
|
||||
<field name="x_fc_final_invoice_id" readonly="1"
|
||||
invisible="not x_fc_final_invoice_id"/>
|
||||
</group>
|
||||
<group string="Delivery">
|
||||
<field name="x_fc_rush_order"/>
|
||||
<field name="x_fc_delivery_method"/>
|
||||
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Customer Reference">
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_contact_phone"/>
|
||||
<field name="x_fc_ship_via"/>
|
||||
</group>
|
||||
<group string="Scheduling">
|
||||
<field name="x_fc_planned_start_date"/>
|
||||
<field name="x_fc_internal_deadline"/>
|
||||
@@ -214,9 +210,45 @@
|
||||
</div>
|
||||
<field name="x_fc_is_blanket_order"/>
|
||||
<field name="x_fc_block_partial_shipments"/>
|
||||
<!-- Lead Time range. Both 0 = "Standard" on
|
||||
the PDF; otherwise renders "X-Y days"
|
||||
(or "X days" if min==max or one is 0). -->
|
||||
<label for="x_fc_lead_time_min_days" string="Lead Time (days)"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_lead_time_min_days" class="oe_inline" style="width: 4em;"/>
|
||||
<span> to </span>
|
||||
<field name="x_fc_lead_time_max_days" class="oe_inline" style="width: 4em;"/>
|
||||
</div>
|
||||
<field name="x_fc_lead_time_display" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Row 2: Invoicing + Delivery (unchanged pairing). -->
|
||||
<group>
|
||||
<group string="Invoicing">
|
||||
<field name="x_fc_invoice_strategy"/>
|
||||
<field name="x_fc_deposit_percent"
|
||||
invisible="x_fc_invoice_strategy != 'deposit'"/>
|
||||
<field name="x_fc_progress_initial_percent"
|
||||
invisible="x_fc_invoice_strategy != 'progress'"/>
|
||||
<field name="x_fc_final_invoice_id" readonly="1"
|
||||
invisible="not x_fc_final_invoice_id"/>
|
||||
</group>
|
||||
<group string="Delivery">
|
||||
<field name="x_fc_rush_order"/>
|
||||
<field name="x_fc_delivery_method"/>
|
||||
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Row 3: Customer Reference + Margin — both short groups, so
|
||||
pairing them keeps the right column from going blank. -->
|
||||
<group>
|
||||
<group string="Customer Reference">
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_contact_phone"/>
|
||||
<field name="x_fc_ship_via"/>
|
||||
</group>
|
||||
<group string="Margin">
|
||||
<div colspan="2"
|
||||
invisible="x_fc_margin_available"
|
||||
@@ -235,14 +267,29 @@
|
||||
<field name="x_fc_margin_available" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Row 4: Notes — two side-by-side textareas instead of the
|
||||
previous broken separator-in-group layout. -->
|
||||
<group>
|
||||
<group string="Internal Notes">
|
||||
<field name="x_fc_internal_note" nolabel="1"
|
||||
placeholder="Internal notes for estimator / planner / shop floor..."/>
|
||||
</group>
|
||||
<separator string="External Notes (customer-visible)"/>
|
||||
<field name="x_fc_external_note"
|
||||
<group string="External Notes (customer-visible)">
|
||||
<field name="x_fc_external_note" nolabel="1"
|
||||
placeholder="Notes that appear on the acknowledgement and portal..."/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Legacy configurator block — invisible on new SOs (only
|
||||
the handful that came through the old quote configurator
|
||||
flow have x_fc_configurator_id set). Kept at the bottom
|
||||
so it doesn't waste vertical space on the common case. -->
|
||||
<group invisible="not x_fc_configurator_id">
|
||||
<group string="Configurator (legacy)">
|
||||
<field name="x_fc_configurator_id" readonly="1"/>
|
||||
<field name="x_fc_process_summary" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
@@ -330,19 +377,29 @@
|
||||
class="btn-primary"
|
||||
display="always"/>
|
||||
</header>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="show"/>
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"/>
|
||||
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"
|
||||
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
<field name="x_fc_part_catalog_id" optional="hide"/>
|
||||
<field name="amount_total" sum="Total"/>
|
||||
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
|
||||
and shows a compact summary (e.g. "M1234, M5678
|
||||
(+3 more)"). The header x_fc_part_catalog_id field
|
||||
is rarely populated in the configurator flow; the
|
||||
line carries the authoritative part link. -->
|
||||
<field name="x_fc_part_numbers_summary" string="Part"
|
||||
optional="show"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
@@ -352,12 +409,21 @@
|
||||
<field name="x_fc_margin_percent" optional="hide"
|
||||
widget="percentage"/>
|
||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||
<!-- Single Job Status pill. Renders as HTML with a
|
||||
per-kind class (.fp-kind-*) so every phase carries
|
||||
its own distinct tint — see
|
||||
static/src/scss/fp_job_status_pill.scss. -->
|
||||
<field name="x_fc_fp_job_status" widget="html"
|
||||
string="Job Status" optional="show"
|
||||
readonly="1"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
optional="hide"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-info="x_fc_receiving_status == 'partial'"
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@@ -427,8 +493,8 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Quotations" decoration-muted="state == 'cancel'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_part_numbers_summary" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="hide"/>
|
||||
<field name="x_fc_customer_job_number" optional="hide"/>
|
||||
@@ -436,15 +502,16 @@
|
||||
<field name="validity_date" string="Expires" optional="show"/>
|
||||
<field name="x_fc_follow_up_date" optional="show"/>
|
||||
<field name="x_fc_follow_up_user_id" optional="show"/>
|
||||
<field name="amount_total" sum="Total"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_is_signed" widget="boolean_toggle"
|
||||
string="Signed" optional="show"/>
|
||||
<field name="x_fc_email_status" widget="badge"
|
||||
optional="show"
|
||||
decoration-info="x_fc_email_status == 'sent'"
|
||||
decoration-warning="x_fc_email_status == 'opened'"
|
||||
decoration-success="x_fc_email_status == 'won'"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@@ -544,7 +611,10 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
|
||||
<!-- ===== Window Action — Confirmed Sale Orders =====
|
||||
The kanban view_mode + kanban view_id are appended in
|
||||
fp_so_job_sort_views.xml after the kanban view is defined, so
|
||||
we don't hit a missing-ref at module load. -->
|
||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||
<field name="name">Sale Orders</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
|
||||
help="Customer's internal job number for cross-referencing. "
|
||||
"Appears on work orders and invoices.",
|
||||
)
|
||||
job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
help='Free-form bucket that groups the new SO in the '
|
||||
'"Sale Orders by Sorting" list. Type a new label in the '
|
||||
'dropdown to create a section on the fly.',
|
||||
)
|
||||
|
||||
# ---- Scheduling ----
|
||||
planned_start_date = fields.Date(
|
||||
@@ -86,6 +93,11 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
internal_deadline = fields.Date(string='Internal Deadline')
|
||||
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
|
||||
# Lead Time — promised production window. Mirrors directly to
|
||||
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
|
||||
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
|
||||
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
|
||||
lead_time_max_days = fields.Integer(string='Lead Time Max (days)')
|
||||
|
||||
# ---- Order flags (Phase B) ----
|
||||
is_blanket_order = fields.Boolean(
|
||||
@@ -528,9 +540,20 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_po_pending': self.po_pending,
|
||||
'x_fc_po_expected_date': self.po_expected_date or False,
|
||||
'x_fc_customer_job_number': self.customer_job_number or False,
|
||||
'x_fc_job_sort_id': self.job_sort_id.id or False,
|
||||
'x_fc_planned_start_date': self.planned_start_date,
|
||||
'x_fc_internal_deadline': self.internal_deadline,
|
||||
'commitment_date': self.customer_deadline,
|
||||
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
|
||||
'x_fc_lead_time_max_days': self.lead_time_max_days or 0,
|
||||
# commitment_date is a Datetime; customer_deadline is a Date.
|
||||
# Assigning a bare Date stores midnight UTC, which renders as
|
||||
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts
|
||||
# May 25 00:00 UTC → May 24 8pm). Combining with noon keeps
|
||||
# the date stable across all reasonable user timezones.
|
||||
'commitment_date': (
|
||||
datetime.combine(self.customer_deadline, time(12, 0))
|
||||
if self.customer_deadline else False
|
||||
),
|
||||
'x_fc_invoice_strategy': self.invoice_strategy,
|
||||
'x_fc_deposit_percent': self.deposit_percent,
|
||||
'x_fc_progress_initial_percent': self.progress_initial_percent,
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
options="{'no_create_edit': True}"
|
||||
invisible="not partner_id"/>
|
||||
<field name="customer_job_number"/>
|
||||
<field name="job_sort_id"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</group>
|
||||
<group string="Purchase Order">
|
||||
<field name="po_number"
|
||||
@@ -95,7 +98,21 @@
|
||||
<group string="Scheduling">
|
||||
<field name="planned_start_date"/>
|
||||
<field name="internal_deadline"/>
|
||||
<field name="customer_deadline"/>
|
||||
<!-- Labelled "Delivery Date" here to match
|
||||
the SO header field of the same name —
|
||||
same field, same value, just consistent
|
||||
wording end-to-end. Backing field is
|
||||
still `customer_deadline` (wizard) →
|
||||
`commitment_date` (SO). -->
|
||||
<field name="customer_deadline" string="Delivery Date"/>
|
||||
<!-- Lead time range (min/max business days).
|
||||
Both 0 = "Standard" on the SO confirm PDF. -->
|
||||
<label for="lead_time_min_days" string="Lead Time (days)"/>
|
||||
<div class="o_row">
|
||||
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
|
||||
<span> to </span>
|
||||
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
|
||||
</div>
|
||||
<field name="is_blanket_order"/>
|
||||
<field name="block_partial_shipments"/>
|
||||
</group>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.16.9',
|
||||
'version': '19.0.10.20.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -57,11 +57,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
'views/fp_workflow_state_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_job_step_quick_look_views.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
'views/fp_job_quality_buttons.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_views.xml',
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/fp_job_consumption_views.xml',
|
||||
'views/fp_step_priority_views.xml',
|
||||
|
||||
@@ -31,4 +31,19 @@
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active" eval="True"/>
|
||||
</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="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -22,6 +22,7 @@ from . import fp_certificate
|
||||
from . import fp_thickness_reading
|
||||
from . import fp_delivery
|
||||
from . import fp_racking_inspection
|
||||
from . import fp_receiving
|
||||
|
||||
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
||||
from . import fp_notification_trigger
|
||||
|
||||
@@ -137,10 +137,13 @@ class AccountMove(models.Model):
|
||||
if not job or not job.portal_job_id:
|
||||
return
|
||||
portal = job.portal_job_id
|
||||
if 'state' in portal._fields:
|
||||
portal.state = 'complete'
|
||||
if 'invoice_ref' in portal._fields:
|
||||
portal.invoice_ref = self.name
|
||||
# Recompute state via the central helper — it'll only land on
|
||||
# 'complete' if the WO is actually done AND the shipment is
|
||||
# delivered. Posting an invoice early no longer skips the floor.
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal._fp_recompute_portal_state()
|
||||
_logger.info(
|
||||
'Invoice %s linked to fp.job %s portal %s',
|
||||
self.name, job.name, portal.name,
|
||||
|
||||
@@ -97,6 +97,81 @@ class FpJob(models.Model):
|
||||
'idempotency. Cleared post-cutover.',
|
||||
)
|
||||
|
||||
# Display formatter — "WO # 00001" used everywhere on tablet/dashboard.
|
||||
# The underlying `name` field stays untouched (WH/JOB/00001) so reports,
|
||||
# emails, and back-office forms continue using their canonical name.
|
||||
# System-wide sequence rename is a separate decision (see spec
|
||||
# 2026-05-22-shopfloor-tablet-redesign-design §6.5).
|
||||
display_wo_name = fields.Char(
|
||||
compute='_compute_display_wo_name',
|
||||
string='WO #',
|
||||
help='Tablet/dashboard formatter — "WO # 00001" derived from name. '
|
||||
'Underlying name field is unchanged.',
|
||||
)
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_display_wo_name(self):
|
||||
for job in self:
|
||||
raw = (job.name or '').strip()
|
||||
if not raw:
|
||||
job.display_wo_name = ''
|
||||
continue
|
||||
# Take the last "/"-separated segment as the number portion.
|
||||
# WH/JOB/00001 → 00001 ; WH/JOB/2026/00042 → 00042 ; 00123 → 00123
|
||||
tail = raw.rsplit('/', 1)[-1]
|
||||
job.display_wo_name = f'WO # {tail}'
|
||||
|
||||
# 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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -552,6 +627,22 @@ class FpJob(models.Model):
|
||||
'context': {'default_job_id': self.id},
|
||||
}
|
||||
|
||||
def action_open_workspace(self):
|
||||
"""Open the JobWorkspace OWL client action focused on this job.
|
||||
|
||||
Spec: 2026-05-22-shopfloor-tablet-redesign — Phase 1 deliverable.
|
||||
Used as the smart-button entry point before the Landing kanban
|
||||
(Phase 3) is shipped, and stays as a back-office shortcut after.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_job_workspace',
|
||||
'name': self.display_wo_name or self.name,
|
||||
'params': {'job_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_finish_current_step(self):
|
||||
"""Steelhead-style header button: finish whatever's currently
|
||||
in_progress and auto-start the next pending/ready step. If
|
||||
@@ -745,16 +836,10 @@ class FpJob(models.Model):
|
||||
'name': self.portal_job_id.name,
|
||||
}
|
||||
|
||||
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
|
||||
# the customer doesn't see internal states. Anything not in this map
|
||||
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
|
||||
_FP_JOB_STATE_TO_PORTAL_STATE = {
|
||||
'confirmed': 'received',
|
||||
'in_progress': 'in_progress',
|
||||
'done': 'ready_to_ship',
|
||||
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
|
||||
# what to surface to the customer.
|
||||
}
|
||||
# Sub-portal state sync — see fusion_plating_portal/.../fp_portal_job.py
|
||||
# `_fp_recompute_portal_state` for the rules. The mapping table that
|
||||
# used to live here was replaced by the helper so shipment / invoice
|
||||
# signals can't drift away from the WO state any more.
|
||||
|
||||
def write(self, vals):
|
||||
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
||||
@@ -783,13 +868,13 @@ class FpJob(models.Model):
|
||||
if job.state != new_state:
|
||||
state_changed_ids.add(job.id)
|
||||
result = super().write(vals)
|
||||
# Mirror state to portal_job for records that actually changed.
|
||||
# Mirror state to portal_job via the central recompute helper, so
|
||||
# the portal state always derives from the WO + shipment + invoice
|
||||
# together rather than the most-recent event flag.
|
||||
if state_changed_ids:
|
||||
target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
|
||||
if target:
|
||||
for job in self.filtered(lambda j: j.id in state_changed_ids):
|
||||
if job.portal_job_id and job.portal_job_id.state != target:
|
||||
job.portal_job_id.sudo().write({'state': target})
|
||||
for job in self.filtered(lambda j: j.id in state_changed_ids):
|
||||
if job.portal_job_id:
|
||||
job.portal_job_id._fp_recompute_portal_state()
|
||||
if not scrap_deltas:
|
||||
return result
|
||||
Hold = (self.env['fusion.plating.quality.hold']
|
||||
|
||||
@@ -85,6 +85,128 @@ class FpJobStep(models.Model):
|
||||
)
|
||||
step.can_start = not bool(blocking)
|
||||
|
||||
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
||||
# Returns kind of blocker + human reason + optional (model, id) jump
|
||||
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
||||
# with can_start as a single source of truth.
|
||||
blocker_kind = fields.Selection(
|
||||
[
|
||||
('none', 'Not blocked'),
|
||||
('predecessor', 'Waiting on predecessor'),
|
||||
('contract_review', 'Contract review pending'),
|
||||
('parts_not_received', 'Parts not received'),
|
||||
('racking_required', 'Racking inspection required'),
|
||||
('manager_input', 'Manager input required'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
compute='_compute_blocker',
|
||||
string='Blocker Kind',
|
||||
)
|
||||
blocker_reason = fields.Char(
|
||||
compute='_compute_blocker',
|
||||
string='Blocker Reason',
|
||||
help='Human-readable explanation surfaced in the GateViz block.',
|
||||
)
|
||||
blocker_jump_target_model = fields.Char(compute='_compute_blocker')
|
||||
blocker_jump_target_id = fields.Integer(compute='_compute_blocker')
|
||||
|
||||
@api.depends(
|
||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
||||
)
|
||||
def _compute_blocker(self):
|
||||
for step in self:
|
||||
# Terminal/in-progress states are never "blocked"
|
||||
if step.state in ('done', 'skipped', 'cancelled', 'in_progress'):
|
||||
step.blocker_kind = 'none'
|
||||
step.blocker_reason = ''
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
continue
|
||||
|
||||
# Predecessor gate — same policy as _compute_can_start
|
||||
if step._fp_should_block_predecessors():
|
||||
earlier_open = step.job_id.step_ids.filtered(lambda x: (
|
||||
x.id != step.id
|
||||
and x.sequence < step.sequence
|
||||
and x.state not in ('done', 'skipped', 'cancelled')
|
||||
))
|
||||
if earlier_open:
|
||||
first_blocker = earlier_open.sorted('sequence')[0]
|
||||
step.blocker_kind = 'predecessor'
|
||||
seq_disp = (first_blocker.sequence or 0) // 10
|
||||
step.blocker_reason = (
|
||||
f'Waiting on Step {seq_disp}: {first_blocker.name}'
|
||||
)
|
||||
step.blocker_jump_target_model = 'fp.job.step'
|
||||
step.blocker_jump_target_id = first_blocker.id
|
||||
continue
|
||||
|
||||
# Future: extend with explicit checks for contract_review /
|
||||
# parts_not_received / racking_required / manager_input as
|
||||
# those gate models mature. For now, default to 'none'.
|
||||
step.blocker_kind = 'none'
|
||||
step.blocker_reason = ''
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
|
||||
# ==================================================================
|
||||
# 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
|
||||
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
||||
@@ -1078,6 +1200,33 @@ class FpJobStep(models.Model):
|
||||
# quick-look modal. The modal is bound via context= on the parent
|
||||
# job form's <field name="step_ids"/> — no TransientModel needed.
|
||||
|
||||
# Job-level context for the quick-look modal — restored after commit
|
||||
# b0070afc accidentally removed these while still referencing them in
|
||||
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
|
||||
# the 2026-05-22 Phase 1-4 deploy).
|
||||
quick_look_partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Customer',
|
||||
related='job_id.partner_id',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Part',
|
||||
related='job_id.part_catalog_id',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_qty = fields.Float(
|
||||
string='Order Qty',
|
||||
related='job_id.qty',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_instruction_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
string='Instruction Images',
|
||||
related='recipe_node_id.instruction_attachment_ids',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_instructions = fields.Html(
|
||||
string='Operator Instructions',
|
||||
related='recipe_node_id.description',
|
||||
|
||||
67
fusion_plating/fusion_plating_jobs/models/fp_receiving.py
Normal file
67
fusion_plating/fusion_plating_jobs/models/fp_receiving.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Adds the Work Order smart button + header action to fp.receiving so
|
||||
# the receiving form mirrors the SO's WO entry point. Button appears
|
||||
# once the receiving is closed and stays until every linked fp.job
|
||||
# reaches state='done'.
|
||||
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
x_fc_fp_job_count = fields.Integer(
|
||||
string='Work Orders',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_show_work_order_btn = fields.Boolean(
|
||||
string='Show Work Order Button',
|
||||
compute='_compute_show_work_order_btn',
|
||||
help='True once this receiving is closed and at least one linked '
|
||||
'work order is still open (state != done). Hidden again '
|
||||
'when every job is done.',
|
||||
)
|
||||
|
||||
def _compute_fp_job_count(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for rec in self:
|
||||
if rec.sale_order_id:
|
||||
rec.x_fc_fp_job_count = Job.search_count(
|
||||
[('sale_order_id', '=', rec.sale_order_id.id)]
|
||||
)
|
||||
else:
|
||||
rec.x_fc_fp_job_count = 0
|
||||
|
||||
def _compute_show_work_order_btn(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
for rec in self:
|
||||
if rec.state != 'closed' or not rec.sale_order_id:
|
||||
rec.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
|
||||
rec.x_fc_show_work_order_btn = bool(jobs) and any(
|
||||
j.state != 'done' for j in jobs
|
||||
)
|
||||
|
||||
def action_view_fp_jobs(self):
|
||||
"""Open the work order(s) linked to this receiving's sale order."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return False
|
||||
jobs = self.env['fp.job'].search([
|
||||
('sale_order_id', '=', self.sale_order_id.id),
|
||||
])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Work Orders'),
|
||||
'res_model': 'fp.job',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.sale_order_id.id)],
|
||||
'context': {'default_sale_order_id': self.sale_order_id.id},
|
||||
}
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
@@ -24,6 +24,13 @@ class SaleOrder(models.Model):
|
||||
string='Work Orders',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_show_work_order_btn = fields.Boolean(
|
||||
string='Show Work Order Header Button',
|
||||
compute='_compute_show_work_order_btn',
|
||||
help='True once any receiving record on this SO has closed and '
|
||||
'at least one work order is still open (state != done). '
|
||||
'Hidden again when every WO is done.',
|
||||
)
|
||||
x_fc_fp_certificate_count = fields.Integer(
|
||||
string='Certificates',
|
||||
compute='_compute_fp_certificate_count',
|
||||
@@ -114,6 +121,25 @@ class SaleOrder(models.Model):
|
||||
[('sale_order_id', '=', so.id)]
|
||||
)
|
||||
|
||||
def _compute_show_work_order_btn(self):
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
for so in self:
|
||||
if Recv is None:
|
||||
so.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
has_closed_recv = bool(Recv.sudo().search_count([
|
||||
('sale_order_id', '=', so.id),
|
||||
('state', '=', 'closed'),
|
||||
]))
|
||||
if not has_closed_recv:
|
||||
so.x_fc_show_work_order_btn = False
|
||||
continue
|
||||
jobs = Job.search([('sale_order_id', '=', so.id)])
|
||||
so.x_fc_show_work_order_btn = bool(jobs) and any(
|
||||
j.state != 'done' for j in jobs
|
||||
)
|
||||
|
||||
def _compute_fp_certificate_count(self):
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
for so in self:
|
||||
|
||||
@@ -2,3 +2,8 @@
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
from . import test_qty_received_propagation
|
||||
from . import test_display_wo_name
|
||||
from . import test_blocker_compute
|
||||
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="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<!-- Phase 1 — Tablet redesign. Opens the JobWorkspace OWL
|
||||
client action focused on this WO. Primary entry point
|
||||
for techs before the Landing kanban (Phase 3) ships;
|
||||
stays as a back-office shortcut after. -->
|
||||
<button name="action_open_workspace" type="object"
|
||||
string="Open Workspace"
|
||||
class="btn-primary"
|
||||
icon="fa-tablet"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_open_process_tree" type="object"
|
||||
string="Process Tree"
|
||||
class="btn-secondary"
|
||||
@@ -63,15 +72,10 @@
|
||||
<field name="all_steps_terminal" invisible="1"/>
|
||||
<field name="next_milestone_action" invisible="1"/>
|
||||
<button name="action_print_sticker" type="object"
|
||||
string="Print Sticker"
|
||||
class="btn-secondary"
|
||||
icon="fa-tag"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_print_wo_detail" type="object"
|
||||
string="Print WO Detail"
|
||||
class="btn-secondary"
|
||||
icon="fa-file-text-o"
|
||||
invisible="state in ('draft', 'cancelled')"/>
|
||||
icon="fa-qrcode"
|
||||
invisible="state == 'draft'"
|
||||
help="Print Sticker"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Adds the Work Order smart button + header button to fp.receiving so
|
||||
the receiving form mirrors the SO's WO entry point. Header button
|
||||
appears once state == 'closed' and at least one linked fp.job is
|
||||
still open. Smart button is always visible when WOs exist.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_receiving_form_fp_jobs" model="ir.ui.view">
|
||||
<field name="name">fp.receiving.form.fp.jobs</field>
|
||||
<field name="model">fp.receiving</field>
|
||||
<field name="inherit_id" ref="fusion_plating_receiving.view_fp_receiving_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Work Order header button — only after receiving is
|
||||
closed and while at least one job is still open. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||
<button name="action_view_fp_jobs"
|
||||
string="Work Order" type="object"
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order smart button on the button_box (mirrors the
|
||||
one on the SO form). Always visible when count > 0. -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_fp_jobs" type="object"
|
||||
class="oe_stat_button" icon="fa-cogs"
|
||||
invisible="x_fc_fp_job_count == 0">
|
||||
<field name="x_fc_fp_job_count" widget="statinfo" string="WO"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -9,20 +9,26 @@
|
||||
site that needs to bring legacy menus back can simply add a
|
||||
user to the group. -->
|
||||
|
||||
<!-- Reset group_ids on the 3 shopfloor menus that used to be
|
||||
<!-- Reset group_ids on the shopfloor menus that used to be
|
||||
hidden — they are now the canonical UIs and should be visible
|
||||
to all users (subject to the original groups= attribute on
|
||||
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
|
||||
</record>
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [])]"/>
|
||||
</record>
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [])]"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 3 tablet redesign (2026-05-22) — the standalone Plant
|
||||
Overview menu is superseded by Workstation > All Plant toggle.
|
||||
The fp_menu.xml record was removed but the database row
|
||||
persists (Odoo doesn't auto-delete orphan records). Force-
|
||||
delete here so the menu disappears from the Shop Floor tree.
|
||||
The action record (action_fp_plant_overview) is kept and
|
||||
retargeted to fp_shopfloor_landing for bookmark back-compat. -->
|
||||
<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>
|
||||
|
||||
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
|
||||
(the bridge module is uninstalled and its menu xmlid no longer
|
||||
resolves). fp.job has its own priority field on the header. -->
|
||||
|
||||
@@ -33,6 +33,19 @@
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order header action — appears once any linked
|
||||
receiving is closed AND at least one WO is still open.
|
||||
Reuses the existing action_view_fp_jobs smart-button
|
||||
target so single-job SOs land on the form directly. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_work_order_btn" invisible="1"/>
|
||||
<button name="action_view_fp_jobs"
|
||||
string="Work Order" type="object"
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this order. Hidden automatically once every linked WO is marked Done."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
|
||||
line under the SO name (the big SO-30000 heading). Only
|
||||
renders once the SO has been confirmed (quote_ref is set
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.3.10.0',
|
||||
'version': '19.0.3.11.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
|
||||
@@ -260,6 +260,58 @@ class FpDelivery(models.Model):
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_delivery_count'
|
||||
|
||||
def action_view_coc(self):
|
||||
"""Open the certificate record this delivery's CoC PDF came
|
||||
from. The attachment carries res_model + res_id, so we
|
||||
navigate to that record (operator gets all cert info — issue
|
||||
date, void wizard, reset, etc.) rather than just opening the
|
||||
raw PDF. Falls back to opening the attachment directly if
|
||||
someone manually attached a PDF that isn't a cert.
|
||||
"""
|
||||
self.ensure_one()
|
||||
att = self.coc_attachment_id
|
||||
if not att:
|
||||
raise UserError(_('No CoC linked to this delivery.'))
|
||||
if att.res_model == 'fp.certificate' and att.res_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Certificate of Conformance'),
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': att.res_id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
# Plain attachment — open via PDF preview helper if available.
|
||||
if hasattr(att, 'action_fusion_preview'):
|
||||
return att.action_fusion_preview(title=att.name or 'CoC')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%d?download=false' % att.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_packing_list(self):
|
||||
"""Open the packing-list PDF via fusion_pdf_preview (or fall
|
||||
back to a new tab when the preview helper isn't installed).
|
||||
Packing lists don't have a backing model — they're attachments
|
||||
only — so we don't navigate to a record.
|
||||
"""
|
||||
self.ensure_one()
|
||||
att = self.packing_list_attachment_id
|
||||
if not att:
|
||||
raise UserError(_('No packing list attached to this delivery.'))
|
||||
if hasattr(att, 'action_fusion_preview'):
|
||||
return att.action_fusion_preview(
|
||||
title=att.name or 'Packing List',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%d?download=false' % att.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_refresh_from_source(self):
|
||||
"""Re-pull delivery address / contact / scheduled date / source
|
||||
facility / carrier / CoC from the linked job → SO → receiving →
|
||||
|
||||
@@ -79,6 +79,30 @@
|
||||
widget="statinfo"
|
||||
string="Outbound Shipment"/>
|
||||
</button>
|
||||
<!-- CoC smart button → cert record (not the
|
||||
raw PDF — operator can print/reset/void
|
||||
from the cert form). -->
|
||||
<button name="action_view_coc"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-certificate"
|
||||
invisible="not coc_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">CoC</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Packing list smart button → PDF preview
|
||||
dialog (packing lists are attachments
|
||||
only, no backing model to navigate to). -->
|
||||
<button name="action_view_packing_list"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-list-alt"
|
||||
invisible="not packing_list_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Packing List</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.4.3.0',
|
||||
'version': '19.0.4.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||
'CoC downloads, invoice access.',
|
||||
|
||||
@@ -263,6 +263,83 @@ class FpPortalJob(models.Model):
|
||||
walk(mo.x_fc_recipe_id, 0)
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# State recompute — single source of truth derived from upstream models
|
||||
# ==========================================================================
|
||||
# The portal state should ALWAYS reflect the real shop-floor state of the
|
||||
# linked fp.job(s), the outbound shipment(s), and the customer invoice.
|
||||
# Earlier paths wrote state directly from each event hook (tracking number
|
||||
# arrived → 'shipped'; invoice posted → 'complete') which drifted out of
|
||||
# sync the moment any of those events fired before the job was actually
|
||||
# done — e.g. a FedEx label booked early would promote portal state to
|
||||
# 'shipped' even though the WO was still in 'confirmed'. The helper below
|
||||
# is the new single source of truth; the hooks now delegate to it.
|
||||
def _fp_recompute_portal_state(self):
|
||||
"""Derive portal state from fp.job + shipment + invoice and write
|
||||
it back if it differs. Safe to call from any sync hook; only
|
||||
writes when the target state actually changes."""
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is None:
|
||||
return
|
||||
for portal in self:
|
||||
jobs = Job.sudo().search([('portal_job_id', '=', portal.id)])
|
||||
if not jobs:
|
||||
# No linked job — leave manual edits alone.
|
||||
continue
|
||||
|
||||
all_done = all(j.state == 'done' for j in jobs)
|
||||
any_in_progress = any(
|
||||
j.state in ('in_progress', 'done') for j in jobs
|
||||
)
|
||||
|
||||
# Walk SO → fp.receiving → fusion.shipment for shipment status.
|
||||
ship_delivered = False
|
||||
ship_in_transit = False
|
||||
for j in jobs:
|
||||
so = j.sale_order_id
|
||||
if not so or 'x_fc_receiving_ids' not in so._fields:
|
||||
continue
|
||||
for recv in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
recv.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in recv._fields
|
||||
else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
if ship.status == 'delivered':
|
||||
ship_delivered = True
|
||||
elif ship.status == 'shipped':
|
||||
ship_in_transit = True
|
||||
|
||||
# Invoice signal — any posted customer invoice on the SO.
|
||||
invoiced = False
|
||||
for j in jobs:
|
||||
so = j.sale_order_id
|
||||
if not so:
|
||||
continue
|
||||
if any(
|
||||
m.state == 'posted' and m.move_type in ('out_invoice', 'out_refund')
|
||||
for m in so.invoice_ids
|
||||
):
|
||||
invoiced = True
|
||||
break
|
||||
|
||||
# Resolve target state.
|
||||
if all_done and invoiced and ship_delivered:
|
||||
target = 'complete'
|
||||
elif all_done and (ship_delivered or ship_in_transit):
|
||||
target = 'shipped'
|
||||
elif all_done:
|
||||
target = 'ready_to_ship'
|
||||
elif any_in_progress:
|
||||
target = 'in_progress'
|
||||
else:
|
||||
target = 'received'
|
||||
|
||||
if portal.state != target:
|
||||
portal.sudo().write({'state': target})
|
||||
|
||||
# ==========================================================================
|
||||
# Per-stage timestamp snapshots
|
||||
# ==========================================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.25.0',
|
||||
'version': '19.0.3.27.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
|
||||
@@ -160,14 +160,14 @@ class FusionShipment(models.Model):
|
||||
vals['packing_list_attachment_id'] = (
|
||||
delivery.packing_list_attachment_id.id
|
||||
)
|
||||
# Once a tracking number exists, the parts have been picked
|
||||
# by the carrier (or are about to be) — advance the portal
|
||||
# state to 'shipped' so the customer sees their order is
|
||||
# on its way. The 'delivered' status flips when FedEx
|
||||
# tracking reports the delivery.
|
||||
if self.tracking_number and portal.state in (
|
||||
'received', 'in_progress', 'ready_to_ship',
|
||||
):
|
||||
vals['state'] = 'shipped'
|
||||
if vals:
|
||||
portal.sudo().write(vals)
|
||||
# State is now derived centrally — see
|
||||
# fusion.plating.portal.job._fp_recompute_portal_state. It
|
||||
# only promotes to 'shipped' when every linked WO is done
|
||||
# AND the shipment.status is 'shipped' or 'delivered'. A
|
||||
# FedEx label booked early (tracking number without the
|
||||
# carrier actually picking up) no longer leapfrogs the
|
||||
# shop floor.
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal.sudo()._fp_recompute_portal_state()
|
||||
|
||||
@@ -15,12 +15,29 @@ class SaleOrder(models.Model):
|
||||
x_fc_receiving_count = fields.Integer(
|
||||
string='Receiving Count', compute='_compute_receiving_count',
|
||||
)
|
||||
x_fc_show_receive_parts_btn = fields.Boolean(
|
||||
string='Show Receive Parts Button',
|
||||
compute='_compute_show_receive_parts_btn',
|
||||
help='True once the SO is confirmed and there is still at least '
|
||||
'one receiving record that is not yet closed. Hidden again '
|
||||
'when every receiving record has been closed.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_receiving_ids')
|
||||
def _compute_receiving_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
|
||||
|
||||
@api.depends('state', 'x_fc_receiving_ids.state')
|
||||
def _compute_show_receive_parts_btn(self):
|
||||
for rec in self:
|
||||
if rec.state not in ('sale', 'done'):
|
||||
rec.x_fc_show_receive_parts_btn = False
|
||||
continue
|
||||
rec.x_fc_show_receive_parts_btn = any(
|
||||
r.state != 'closed' for r in rec.x_fc_receiving_ids
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create receiving record on SO confirmation.
|
||||
|
||||
|
||||
@@ -76,3 +76,25 @@ $fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Receive Parts header button — dark yellow / goldenrod
|
||||
// Applied to the SO-form header action that opens the receiving record(s).
|
||||
// Uses !important to defeat the .o_form_statusbar .btn cascades in both
|
||||
// the light and dark Odoo bundles.
|
||||
// =============================================================================
|
||||
.o_fp_receive_parts_btn,
|
||||
.o_form_statusbar .o_statusbar_buttons .o_fp_receive_parts_btn,
|
||||
button.o_fp_receive_parts_btn {
|
||||
background-color: #b8860b !important;
|
||||
border-color: #9e7700 !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: #9e7700 !important;
|
||||
border-color: #7a5b00 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.fa { color: #ffffff !important; }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
<field name="x_fc_receiving_count" widget="statinfo" string="Receiving"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Receive Parts header action — appears after SO confirmation
|
||||
while at least one receiving record is still open, and
|
||||
disappears once every receiving record is closed. Reuses
|
||||
the existing action_view_receiving method so a single
|
||||
receiving opens directly while multiples land on the list. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_show_receive_parts_btn" invisible="1"/>
|
||||
<button name="action_view_receiving"
|
||||
string="Receive Parts" type="object"
|
||||
class="btn o_fp_receive_parts_btn"
|
||||
icon="fa-archive"
|
||||
invisible="not x_fc_show_receive_parts_btn"
|
||||
help="Open the receiving record(s) for this order. Hidden automatically once all receiving records are closed."/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.24.0',
|
||||
'version': '19.0.11.26.30',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -90,7 +90,12 @@
|
||||
<t t-set="_desc"
|
||||
t-value="line.fp_customer_description() if _has_helper else line.name"/>
|
||||
<span t-esc="_desc" style="white-space: pre-line;"/>
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||
<!-- Serial line is suppressed when the calling template sets
|
||||
`fp_no_serial_in_desc=True` in `line.env.context`. SO
|
||||
portrait does this because it shows the serial in its own
|
||||
column in the part-number cell — otherwise it'd be
|
||||
duplicated. Invoice / packing slip still display it here. -->
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id and not line.env.context.get('fp_no_serial_in_desc')">
|
||||
<br/>
|
||||
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
|
||||
</t>
|
||||
|
||||
@@ -41,6 +41,58 @@
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- Compact A4 Portrait for customer-facing reports -->
|
||||
<!-- (SO confirmation, quotation, invoice, packing slip, BoL). -->
|
||||
<!-- Keeps the external_layout header band (logo + company addr) -->
|
||||
<!-- but shrinks the reserved zone from Odoo's default ~40mm to -->
|
||||
<!-- 22mm so the document title sits ~5mm under the logo instead -->
|
||||
<!-- of 30mm. header_spacing kept at 3mm so the header HTML never -->
|
||||
<!-- bleeds into body content on a page break. See CLAUDE.md row -->
|
||||
<!-- "wkhtmltopdf header overlap" for the underlying mechanic. -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="paperformat_fp_a4_portrait" model="report.paperformat">
|
||||
<field name="name">Fusion Plating A4 Portrait (Compact)</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="format">A4</field>
|
||||
<field name="orientation">Portrait</field>
|
||||
<!-- Mirrors paperformat_fp_coc exactly. Tiny margin_top (8mm)
|
||||
puts the rendered header band flush at the top of the page;
|
||||
the body clears the header via padding-top on the .fp-sale
|
||||
wrapper (same way .fp-coc { padding-top: 20mm } clears it
|
||||
on the CoC). DO NOT set margin_top to "the header height"
|
||||
— that forces the header to live entirely inside the
|
||||
reserved zone and any header growth = body overlap.
|
||||
margin_top=8 + padding-on-wrapper is the proven shape. -->
|
||||
<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>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 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 -->
|
||||
<!-- ============================================================= -->
|
||||
@@ -266,6 +318,7 @@
|
||||
<field name="print_report_name">(object.state in ('draft', 'sent') and 'Quotation - %s' % object.name) or 'Order - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_sale_landscape" model="ir.actions.report">
|
||||
@@ -361,9 +414,10 @@
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.sale_id.name.rsplit('-', 1)[-1] if object.sale_id else object.name)</field>
|
||||
<field name="binding_model_id" ref="stock.model_stock_picking"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_packing_slip_landscape" model="ir.actions.report">
|
||||
@@ -372,7 +426,7 @@
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_landscape</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_landscape</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.sale_id.name.rsplit('-', 1)[-1] if object.sale_id else object.name)</field>
|
||||
<field name="binding_model_id" ref="stock.model_stock_picking"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
@@ -417,6 +471,11 @@
|
||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="is_invoice_report" eval="True"/>
|
||||
<!-- Same compact paperformat as the SO confirmation so the
|
||||
inline custom header sits at the top of the page (not 40mm
|
||||
down under Odoo's default margin). See CLAUDE.md
|
||||
"wkhtmltopdf header overlap — the CoC pattern". -->
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
|
||||
@@ -428,7 +487,10 @@
|
||||
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
<!-- Compact landscape paperformat (same shape as the portrait
|
||||
compact, just rotated) so the inline header lands at the
|
||||
top with no auto-margin gap. -->
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape_compact"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
@@ -14,26 +14,92 @@
|
||||
<template id="report_fp_invoice_portrait">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<div class="fp-report">
|
||||
<div class="page">
|
||||
<t t-set="form_code" t-value="'FRM-007'"/>
|
||||
<t t-call="fusion_plating_reports.fp_external_layout_clean">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id or env.company"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
|
||||
<h4>
|
||||
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
|
||||
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
|
||||
<span t-field="doc.name"/>
|
||||
</h4>
|
||||
<!-- Compute helpers -->
|
||||
<t t-set="title_en" t-value="
|
||||
'Credit Note' if doc.move_type == 'out_refund'
|
||||
else 'Vendor Bill' if doc.move_type == 'in_invoice'
|
||||
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Invoice'"/>
|
||||
<t t-set="title_fr" t-value="
|
||||
'Note de crédit' if doc.move_type == 'out_refund'
|
||||
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
|
||||
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Facture'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
|
||||
<!-- Pull FP-specific fields from the source SO when invoice_origin
|
||||
names one. Falls back to an empty recordset on manual
|
||||
invoices so the t-if guards in the markup stay clean. -->
|
||||
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
|
||||
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
|
||||
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
|
||||
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
|
||||
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
|
||||
|
||||
<div class="fp-report fp-sale">
|
||||
<!-- Inline 3-column header: logo+address LEFT,
|
||||
NADCAP MIDDLE, title+barcode RIGHT.
|
||||
Mirrors SO confirmation exactly. -->
|
||||
<div class="fp-sale-header-row">
|
||||
<div class="fp-sale-header-left">
|
||||
<t t-if="logo_uri">
|
||||
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
|
||||
</t>
|
||||
<div class="fp-sale-company-addr">
|
||||
<div>
|
||||
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
|
||||
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
|
||||
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
|
||||
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
|
||||
</div>
|
||||
<div t-if="company.phone or company_fax">
|
||||
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
|
||||
<t t-if="company.phone and company_fax">   </t>
|
||||
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
|
||||
</div>
|
||||
<div t-if="company.partner_id.website">
|
||||
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-sale-header-mid">
|
||||
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
|
||||
<img class="fp-nadcap-logo"
|
||||
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
|
||||
alt="Nadcap Accredited"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fp-sale-header-right">
|
||||
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Billing / Shipping -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
||||
<th style="width: 50%;">DELIVERY ADDRESS</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
|
||||
</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -56,53 +122,131 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Invoice info -->
|
||||
<!-- Row 1: Invoice Date | Due Date | Sales Rep | Customer PO # | Payment Ref -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 25%;">INVOICE DATE</th>
|
||||
<th class="info-header" style="width: 25%;">DUE DATE</th>
|
||||
<th class="info-header" style="width: 25%;">SOURCE</th>
|
||||
<th class="info-header" style="width: 25%;">SALES REP</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Invoice Date</span>
|
||||
<span class="fp-bl-fr-stk">Date de facture</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Due Date</span>
|
||||
<span class="fp-bl-fr-stk">Date d'échéance</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Sales Rep</span>
|
||||
<span class="fp-bl-fr-stk">Vendeur</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Customer PO #</span>
|
||||
<span class="fp-bl-fr-stk">N° de B/C client</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Payment Ref</span>
|
||||
<span class="fp-bl-fr-stk">Réf. paiement</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.invoice_date"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
|
||||
<td class="text-center"><span t-esc="po_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Lines -->
|
||||
<!-- Row 2: Customer Job # | Specification | Delivery Method (pulled from source SO; hidden on manual invoices). -->
|
||||
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 34%;">
|
||||
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 33%;">
|
||||
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 33%;">
|
||||
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Lines (taxes column dropped; summarized in totals) -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 20%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 32%;">DESCRIPTION</th>
|
||||
<th style="width: 8%;">QTY</th>
|
||||
<th style="width: 8%;">UOM</th>
|
||||
<th style="width: 12%;">UNIT PRICE</th>
|
||||
<th style="width: 8%;">TAXES</th>
|
||||
<th style="width: 12%;">AMOUNT</th>
|
||||
<th class="text-start" style="width: 24%;">
|
||||
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th class="text-start" style="width: 38%;">
|
||||
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
|
||||
</th>
|
||||
<th style="width: 8%;">
|
||||
<span class="fp-bl-en-stk">Qty</span>
|
||||
<span class="fp-bl-fr-stk">Qté</span>
|
||||
</th>
|
||||
<th style="width: 8%;">
|
||||
<span class="fp-bl-en-stk">UOM</span>
|
||||
<span class="fp-bl-fr-stk">UDM</span>
|
||||
</th>
|
||||
<th style="width: 11%;">
|
||||
<span class="fp-bl-en-stk">Unit Price</span>
|
||||
<span class="fp-bl-fr-stk">Prix unitaire</span>
|
||||
</th>
|
||||
<th style="width: 11%;">
|
||||
<span class="fp-bl-en-stk">Amount</span>
|
||||
<span class="fp-bl-fr-stk">Montant</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.invoice_line_ids" t-as="line">
|
||||
<t t-if="line.display_type == 'line_section'">
|
||||
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
|
||||
<tr class="section-row"><td colspan="6"><strong t-field="line.name"/></td></tr>
|
||||
</t>
|
||||
<t t-elif="line.display_type == 'line_note'">
|
||||
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
|
||||
<tr class="note-row"><td colspan="6"><span t-field="line.name"/></td></tr>
|
||||
</t>
|
||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
<!-- Three stacked lines: Part #, Name, S/N -->
|
||||
<div>
|
||||
<strong>Part #:</strong>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Name:</strong>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
<div>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||
<span t-esc="line.x_fc_serial_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Suppress duplicate serial in the description column -->
|
||||
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@@ -112,9 +256,6 @@
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
@@ -127,40 +268,47 @@
|
||||
<!-- Terms + Totals -->
|
||||
<div class="row" style="margin-top: 15px;">
|
||||
<div class="col-6">
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<strong>Payment Terms:</strong><br/>
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-if="doc.payment_reference">
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Payment Reference:</strong>
|
||||
<span t-field="doc.payment_reference"/>
|
||||
</div>
|
||||
<t t-if="doc.invoice_payment_term_id">
|
||||
<strong>Payment Terms / Modalités de paiement:</strong><br/>
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="doc.invoice_payment_term_id.name"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="col-6" style="text-align: right;">
|
||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="min-width: 150px;">Subtotal</td>
|
||||
<td style="min-width: 150px;">
|
||||
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
|
||||
</td>
|
||||
<td class="text-end" style="min-width: 110px;">
|
||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Taxes</td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="background-color: #c1c1c1;">
|
||||
<td><strong>Grand Total</strong></td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
</tr>
|
||||
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
||||
<tr>
|
||||
<td><strong>Amount Due</strong></td>
|
||||
<td>
|
||||
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
@@ -173,14 +321,14 @@
|
||||
<!-- Paid stamp -->
|
||||
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
||||
<div style="margin-top: 15px; text-align: center;">
|
||||
<span class="paid-stamp">PAID</span>
|
||||
<span class="paid-stamp">PAID / PAYÉ</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Notes -->
|
||||
<t t-if="doc.narration">
|
||||
<div style="margin-top: 15px;">
|
||||
<strong>Notes:</strong>
|
||||
<strong>Notes / Remarques:</strong>
|
||||
<div t-field="doc.narration"/>
|
||||
</div>
|
||||
</t>
|
||||
@@ -198,26 +346,87 @@
|
||||
<template id="report_fp_invoice_landscape">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
|
||||
<div class="fp-landscape">
|
||||
<div class="page">
|
||||
<t t-set="form_code" t-value="'FRM-007'"/>
|
||||
<t t-call="fusion_plating_reports.fp_external_layout_clean">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id or env.company"/>
|
||||
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
|
||||
<h2 style="text-align: left;">
|
||||
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
|
||||
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
|
||||
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
|
||||
<span t-field="doc.name"/>
|
||||
</h2>
|
||||
<!-- Same compute helpers as portrait -->
|
||||
<t t-set="title_en" t-value="
|
||||
'Credit Note' if doc.move_type == 'out_refund'
|
||||
else 'Vendor Bill' if doc.move_type == 'in_invoice'
|
||||
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Invoice'"/>
|
||||
<t t-set="title_fr" t-value="
|
||||
'Note de crédit' if doc.move_type == 'out_refund'
|
||||
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
|
||||
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
|
||||
else 'Facture'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
|
||||
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
|
||||
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
|
||||
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
|
||||
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
|
||||
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
|
||||
|
||||
<div class="fp-landscape fp-sale">
|
||||
<!-- 3-column inline header — same as portrait -->
|
||||
<div class="fp-sale-header-row">
|
||||
<div class="fp-sale-header-left">
|
||||
<t t-if="logo_uri">
|
||||
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
|
||||
</t>
|
||||
<div class="fp-sale-company-addr">
|
||||
<div>
|
||||
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
|
||||
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
|
||||
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
|
||||
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
|
||||
</div>
|
||||
<div t-if="company.phone or company_fax">
|
||||
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
|
||||
<t t-if="company.phone and company_fax">   </t>
|
||||
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
|
||||
</div>
|
||||
<div t-if="company.partner_id.website">
|
||||
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fp-sale-header-mid">
|
||||
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
|
||||
<img class="fp-nadcap-logo"
|
||||
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
|
||||
alt="Nadcap Accredited"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fp-sale-header-right">
|
||||
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Billing / Shipping -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
||||
<th style="width: 50%;">DELIVERY ADDRESS</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
|
||||
</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -240,44 +449,106 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Invoice info (wide) -->
|
||||
<!-- Invoice info row (wide, 6 cols on landscape) -->
|
||||
<table class="bordered info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>INVOICE DATE</th>
|
||||
<th>DUE DATE</th>
|
||||
<th>SOURCE</th>
|
||||
<th>SALES REP</th>
|
||||
<th>PAYMENT REF</th>
|
||||
<th>CURRENCY</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Invoice Date</span>
|
||||
<span class="fp-bl-fr-stk">Date de facture</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Due Date</span>
|
||||
<span class="fp-bl-fr-stk">Date d'échéance</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Sales Rep</span>
|
||||
<span class="fp-bl-fr-stk">Vendeur</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Customer PO #</span>
|
||||
<span class="fp-bl-fr-stk">N° de B/C client</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Payment Ref</span>
|
||||
<span class="fp-bl-fr-stk">Réf. paiement</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en-stk">Currency</span>
|
||||
<span class="fp-bl-fr-stk">Devise</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.invoice_date"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
|
||||
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
|
||||
<td class="text-center"><span t-esc="doc.payment_reference or '-'"/></td>
|
||||
<td class="text-center"><span t-esc="po_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
|
||||
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Lines — hide discount column unless at least one line has a discount -->
|
||||
<!-- Source SO row (wider, 3 cols, inline). Hidden on manual invoices. -->
|
||||
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
|
||||
<table class="bordered info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
|
||||
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Lines (taxes column dropped; discount column conditional) -->
|
||||
<t t-set="has_discount" t-value="any(l.discount for l in doc.invoice_line_ids)"/>
|
||||
<t t-set="col_count" t-value="8 if has_discount else 7"/>
|
||||
<t t-set="col_count" t-value="7 if has_discount else 6"/>
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 18%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 24%;">DESCRIPTION</th>
|
||||
<th style="width: 8%;">QTY</th>
|
||||
<th style="width: 8%;">UOM</th>
|
||||
<th style="width: 12%;">UNIT PRICE</th>
|
||||
<th t-if="has_discount" style="width: 10%;">DISCOUNT</th>
|
||||
<th style="width: 10%;">TAXES</th>
|
||||
<th style="width: 10%;">AMOUNT</th>
|
||||
<th class="text-start" style="width: 22%;">
|
||||
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th class="text-start" style="width: 30%;">
|
||||
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
|
||||
</th>
|
||||
<th style="width: 7%;">
|
||||
<span class="fp-bl-en-stk">Qty</span>
|
||||
<span class="fp-bl-fr-stk">Qté</span>
|
||||
</th>
|
||||
<th style="width: 7%;">
|
||||
<span class="fp-bl-en-stk">UOM</span>
|
||||
<span class="fp-bl-fr-stk">UDM</span>
|
||||
</th>
|
||||
<th style="width: 12%;">
|
||||
<span class="fp-bl-en-stk">Unit Price</span>
|
||||
<span class="fp-bl-fr-stk">Prix unitaire</span>
|
||||
</th>
|
||||
<th t-if="has_discount" style="width: 10%;">
|
||||
<span class="fp-bl-en-stk">Discount</span>
|
||||
<span class="fp-bl-fr-stk">Remise</span>
|
||||
</th>
|
||||
<th style="width: 12%;">
|
||||
<span class="fp-bl-en-stk">Amount</span>
|
||||
<span class="fp-bl-fr-stk">Montant</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -291,9 +562,28 @@
|
||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
<!-- Three stacked lines: Part #, Name, S/N -->
|
||||
<div>
|
||||
<strong>Part #:</strong>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Name:</strong>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
<div>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
|
||||
<span t-esc="line.x_fc_serial_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@@ -305,10 +595,7 @@
|
||||
</td>
|
||||
<td t-if="has_discount" class="text-center">
|
||||
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
|
||||
<t t-else="">—</t>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
@@ -322,34 +609,47 @@
|
||||
<!-- Terms + Totals -->
|
||||
<div class="row" style="margin-top: 15px;">
|
||||
<div class="col-7">
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<strong>Payment Terms:</strong><br/>
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
<t t-if="doc.invoice_payment_term_id">
|
||||
<strong>Payment Terms / Modalités de paiement:</strong><br/>
|
||||
<t t-if="doc.invoice_payment_term_id.note">
|
||||
<span t-field="doc.invoice_payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="doc.invoice_payment_term_id.name"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="col-5" style="text-align: right;">
|
||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="min-width: 200px;">Subtotal</td>
|
||||
<td style="min-width: 200px;">
|
||||
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
|
||||
</td>
|
||||
<td class="text-end" style="min-width: 150px;">
|
||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Taxes</td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="background-color: #c1c1c1;">
|
||||
<td><strong>Grand Total</strong></td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
</tr>
|
||||
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
|
||||
<tr>
|
||||
<td><strong>Amount Due</strong></td>
|
||||
<td>
|
||||
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
@@ -362,7 +662,7 @@
|
||||
<!-- Paid stamp -->
|
||||
<t t-if="doc.payment_state in ('paid', 'in_payment')">
|
||||
<div style="margin-top: 15px; text-align: center;">
|
||||
<span class="paid-stamp">PAID</span>
|
||||
<span class="paid-stamp">PAID / PAYÉ</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -3,11 +3,185 @@
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Fusion Plating — Packing Slip / Shipping Confirmation (Portrait + Landscape).
|
||||
Binds to stock.picking. Shows parts, quantities, lot/serial tracking,
|
||||
and a receiver sign-off.
|
||||
Binds to stock.picking. Bill-To / Ship-To boxes, bilingual column
|
||||
headers, Received-By signature block and a QR code for scan-to-sign.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- Shared bits -->
|
||||
<!-- ============================================================= -->
|
||||
<template id="fp_packing_slip_styles">
|
||||
<style>
|
||||
/* CoC pattern: paperformat_fp_a4_portrait reserves only
|
||||
margin_top=8mm; the body padding-top clears the header
|
||||
band. Same trick as .fp-coc and .fp-sale. */
|
||||
.fp-report.fp-ps .page { padding-top: 20mm; }
|
||||
/* Title bar: float div layout (NO table — see CLAUDE.md
|
||||
wkhtmltopdf overlap §2). Stacked bilingual title with
|
||||
English bold on top and French italic-grey below. */
|
||||
.fp-ps-titlebar { margin: 0 0 10px 0; padding: 0; overflow: hidden; }
|
||||
.fp-ps-title-en { font-size: 18pt; font-weight: bold; color: #2e2e2e; line-height: 1.1; display: block; }
|
||||
.fp-ps-title-fr { font-size: 13pt; font-style: italic; color: #555; line-height: 1.1; display: block; margin-top: 2px; }
|
||||
.fp-ps-title-num { font-weight: bold; margin-left: 6px; }
|
||||
/* Code128 barcode block (top-right of title bar). Same
|
||||
sizing/centering as the SO confirmation; encodes the
|
||||
packing-slip number so the printed label matches the
|
||||
visible title. */
|
||||
.fp-ps-barcode { float: right; margin-left: 12px; }
|
||||
.fp-ps-barcode .fp-bc-wrap { display: inline-block; text-align: center; }
|
||||
.fp-ps-barcode img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
|
||||
.fp-ps-barcode .fp-bc-label { font-size: 10pt; color: #333; margin-top: 6px; letter-spacing: 0.5px; }
|
||||
.fp-ps-addrtable td { vertical-align: top; padding: 8px 10px; font-size: 10pt; }
|
||||
.fp-ps-addrtable .fp-ps-addr-label { font-weight: bold; font-size: 9pt; color: #333; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.fp-ps-info-table th { background-color: #eaeaea; }
|
||||
.fp-ps-info-table td { text-align: center; font-size: 11pt; padding: 8px; }
|
||||
.fp-ps-items-table th { font-size: 8.5pt; line-height: 1.1; padding: 4px 4px; }
|
||||
.fp-ps-items-table th .fp-fr { display: block; font-weight: normal; color: #555; font-size: 7.5pt; }
|
||||
.fp-ps-items-table td { font-size: 9.5pt; padding: 5px 5px; }
|
||||
.fp-ps-num { text-align: center; }
|
||||
.fp-ps-sig-table td { padding: 10px 12px; vertical-align: top; }
|
||||
.fp-ps-sig-line { border-bottom: 1px solid #000; min-height: 38px; margin-top: 4px; }
|
||||
.fp-ps-sig-label { font-weight: bold; font-size: 9pt; text-transform: uppercase; color: #333; }
|
||||
.fp-ps-sig-sub { font-size: 8pt; color: #666; }
|
||||
.fp-ps-qr-box { text-align: center; padding: 6px; }
|
||||
.fp-ps-qr-box img { width: 110px; height: 110px; display: inline-block; }
|
||||
.fp-ps-qr-caption { font-size: 9pt; color: #333; margin-top: 4px; line-height: 1.2; }
|
||||
.fp-ps-qr-caption .fp-fr { display: block; color: #666; font-size: 8pt; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- Address box content (shared by portrait + landscape).
|
||||
|
||||
NOTE: `t-field` in Odoo 19 requires a dotted path
|
||||
("record.field_name") — passing a bare `partner` variable
|
||||
via t-set and then `t-field="partner"` raises
|
||||
`AssertionError: t-field must have at least a dot`.
|
||||
The contact-widget pattern (`t-field="x.partner_id"
|
||||
t-options="{'widget': 'contact', ...}"`) only works when
|
||||
x.partner_id is a real field traversal on the document.
|
||||
So we render the address parts manually with `t-esc` and
|
||||
per-field guards. -->
|
||||
<template id="fp_packing_slip_addr_block">
|
||||
<div class="fp-ps-addr-label" t-esc="label"/>
|
||||
<strong><span t-esc="partner.name or ''"/></strong>
|
||||
<div t-if="partner.street"><span t-esc="partner.street"/></div>
|
||||
<div t-if="partner.street2"><span t-esc="partner.street2"/></div>
|
||||
<div t-if="partner.city or partner.state_id or partner.zip">
|
||||
<t t-if="partner.city"><span t-esc="partner.city"/></t><t t-if="partner.state_id">, <span t-esc="partner.state_id.code or partner.state_id.name"/></t><t t-if="partner.zip"> <span t-esc="partner.zip"/></t>
|
||||
</div>
|
||||
<div t-if="partner.country_id"><span t-esc="partner.country_id.name"/></div>
|
||||
<div t-if="partner.phone"><span t-esc="partner.phone"/></div>
|
||||
<div t-if="partner.email"><span t-esc="partner.email"/></div>
|
||||
</template>
|
||||
|
||||
<!-- Items table (shared markup; only widths change between layouts) -->
|
||||
<template id="fp_packing_slip_items">
|
||||
<table class="bordered fp-ps-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th t-att-style="w_ordered or 'width: 8%;'">
|
||||
Ordered<span class="fp-fr">Comm.</span>
|
||||
</th>
|
||||
<th t-att-style="w_shipped or 'width: 8%;'">
|
||||
Shipped<span class="fp-fr">EXP</span>
|
||||
</th>
|
||||
<th t-att-style="w_bo or 'width: 8%;'">
|
||||
B/O<span class="fp-fr">À venir</span>
|
||||
</th>
|
||||
<th class="text-start" t-att-style="w_part or 'width: 17%;'">
|
||||
Part Number<span class="fp-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th t-att-style="w_po or 'width: 11%;'">
|
||||
PO<span class="fp-fr">B/C</span>
|
||||
</th>
|
||||
<th t-att-style="w_wo or 'width: 11%;'">
|
||||
WO<span class="fp-fr">B/T</span>
|
||||
</th>
|
||||
<th t-att-style="w_process or 'width: 14%;'">
|
||||
Process<span class="fp-fr">Procédé</span>
|
||||
</th>
|
||||
<th class="text-start" t-att-style="w_desc or 'width: 23%;'">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.move_ids" t-as="move">
|
||||
<t t-set="line" t-value="move.sale_line_id or move"/>
|
||||
<t t-set="ordered_qty" t-value="move.product_uom_qty or 0.0"/>
|
||||
<t t-set="done_qty" t-value="move.quantity or 0.0"/>
|
||||
<t t-set="bo_qty" t-value="ordered_qty - done_qty if ordered_qty > done_qty else 0.0"/>
|
||||
<t t-set="wo_job" t-value="doc.env['fp.job'].search([('sale_order_line_ids', 'in', move.sale_line_id.ids)], limit=1) if move.sale_line_id else doc.env['fp.job']"/>
|
||||
<t t-set="proc_variant" t-value="(move.sale_line_id.x_fc_process_variant_id if move.sale_line_id and 'x_fc_process_variant_id' in move.sale_line_id._fields else False)"/>
|
||||
<t t-set="proc_label" t-value="(proc_variant.variant_label or proc_variant.name) if proc_variant else ((move.sale_line_id.x_fc_part_catalog_id.default_process_id.variant_label or move.sale_line_id.x_fc_part_catalog_id.default_process_id.name) if move.sale_line_id and move.sale_line_id.x_fc_part_catalog_id and move.sale_line_id.x_fc_part_catalog_id.default_process_id else '')"/>
|
||||
<tr>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(ordered_qty) if ordered_qty == int(ordered_qty) else ordered_qty"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(done_qty) if done_qty == int(done_qty) else done_qty"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(bo_qty) if bo_qty == int(bo_qty) else bo_qty"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="po_number or '-'"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<t t-if="wo_job"><span t-esc="wo_job.name"/></t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="proc_label or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<!-- Signature + QR strip (shared) -->
|
||||
<template id="fp_packing_slip_signoff">
|
||||
<table class="bordered fp-ps-sig-table" style="margin-top: 14px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 38%;">
|
||||
<div class="fp-ps-sig-label">
|
||||
Received By
|
||||
<span style="font-weight: normal; color: #666; font-size: 8pt;"> / Reçu par</span>
|
||||
</div>
|
||||
<div class="fp-ps-sig-line"/>
|
||||
<div class="fp-ps-sig-sub">Print name & signature</div>
|
||||
</td>
|
||||
<td style="width: 32%;">
|
||||
<div class="fp-ps-sig-label">
|
||||
Received Date
|
||||
<span style="font-weight: normal; color: #666; font-size: 8pt;"> / Date de réception</span>
|
||||
</div>
|
||||
<div class="fp-ps-sig-line"/>
|
||||
<div class="fp-ps-sig-sub">YYYY-MM-DD</div>
|
||||
</td>
|
||||
<td style="width: 30%;" class="fp-ps-qr-box">
|
||||
<t t-if="qr_uri">
|
||||
<img t-att-src="qr_uri" alt="QR Code"/>
|
||||
</t>
|
||||
<div class="fp-ps-qr-caption">
|
||||
Scan the QR Code to Sign
|
||||
<span class="fp-fr">Scannez le code QR pour signer</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- PORTRAIT -->
|
||||
<!-- ============================================================= -->
|
||||
@@ -16,100 +190,127 @@
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<div class="fp-report">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
|
||||
|
||||
<!-- =========================================
|
||||
Pre-compute fields from the picking chain.
|
||||
doc → stock.picking, doc.sale_id → SO,
|
||||
partner_invoice_id → bill-to (falls back
|
||||
to commercial_partner). carrier presence
|
||||
decides "Ready for pick up" vs tracking ref.
|
||||
========================================= -->
|
||||
<t t-set="bill_partner" t-value="(doc.sale_id.partner_invoice_id if doc.sale_id and doc.sale_id.partner_invoice_id else (doc.partner_id.commercial_partner_id or doc.partner_id))"/>
|
||||
<t t-set="ship_partner" t-value="doc.partner_id"/>
|
||||
<t t-set="has_carrier" t-value="'carrier_id' in doc._fields and doc.carrier_id"/>
|
||||
<t t-set="ship_via" t-value="(doc.carrier_id.name if has_carrier else (doc.sale_id.x_fc_ship_via if doc.sale_id and 'x_fc_ship_via' in doc.sale_id._fields and doc.sale_id.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
|
||||
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
|
||||
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
|
||||
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
|
||||
|
||||
<!-- Packing slip number derived from the SO so it
|
||||
matches the order/work order number: SO-30045
|
||||
→ "30045". When the SO has multiple outbound
|
||||
pickings (partial shipments), the 2nd onwards
|
||||
get a -NN suffix: "30045-02", "30045-03". The
|
||||
first/only picking is just the bare number.
|
||||
Falls back to picking name (WH/OUT/00168) for
|
||||
pickings without a linked SO. -->
|
||||
<t t-set="so_name_raw" t-value="doc.sale_id.name if doc.sale_id else doc.name"/>
|
||||
<t t-set="ps_base_num" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
|
||||
<t t-set="outbound_pickings" t-value="doc.sale_id.picking_ids.filtered(lambda p: p.picking_type_id and p.picking_type_id.code == 'outgoing') if doc.sale_id else doc"/>
|
||||
<t t-set="picking_position" t-value="(sorted(outbound_pickings.ids).index(doc.id) + 1) if (doc.sale_id and doc.id in outbound_pickings.ids) else 1"/>
|
||||
<t t-set="ps_number" t-value="ps_base_num if picking_position <= 1 else ('%s-%02d' % (ps_base_num, picking_position))"/>
|
||||
|
||||
<!-- Code128 barcode encoding the packing-slip number so
|
||||
the printed label matches the visible title. Inlined
|
||||
via barcode_data_uri (no /report/barcode/ HTTP fetch
|
||||
— wkhtmltopdf network calls fail on entech). -->
|
||||
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
|
||||
|
||||
<t t-set="qr_payload" t-value="doc.name or ''"/>
|
||||
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
|
||||
|
||||
<div class="fp-report fp-ps">
|
||||
<div class="page">
|
||||
|
||||
<h4>
|
||||
Packing Slip #
|
||||
<span t-field="doc.name"/>
|
||||
</h4>
|
||||
<!-- Bilingual title (EN bold on top, FR
|
||||
italic-grey below) matching SO and CoC. -->
|
||||
<div class="fp-ps-titlebar">
|
||||
<t t-if="ps_barcode_uri">
|
||||
<div class="fp-ps-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
|
||||
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-ps-title-en">
|
||||
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
|
||||
</span>
|
||||
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
|
||||
</div>
|
||||
|
||||
<!-- From / To -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">FROM</th>
|
||||
<th style="width: 50%;">SHIP TO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Bill To / Ship To -->
|
||||
<table class="bordered fp-ps-addrtable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="height: 80px;">
|
||||
<strong><span t-field="doc.company_id.name"/></strong><br/>
|
||||
<div t-field="doc.company_id.partner_id"
|
||||
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
|
||||
</td>
|
||||
<td style="height: 80px;">
|
||||
<strong><span t-field="doc.partner_id.name"/></strong><br/>
|
||||
<div t-field="doc.partner_id"
|
||||
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Shipment info -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 25%;">SHIP DATE</th>
|
||||
<th class="info-header" style="width: 25%;">SOURCE</th>
|
||||
<th class="info-header" style="width: 25%;">OPERATION</th>
|
||||
<th class="info-header" style="width: 25%;">CARRIER</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-esc="doc.origin or '-'"/></td>
|
||||
<td class="text-center"><span t-field="doc.picking_type_id"/></td>
|
||||
<td class="text-center">
|
||||
<t t-if="'carrier_id' in doc._fields and doc.carrier_id">
|
||||
<span t-field="doc.carrier_id"/>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Bill To:'"/>
|
||||
<t t-set="partner" t-value="bill_partner"/>
|
||||
</t>
|
||||
</td>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Ship To:'"/>
|
||||
<t t-set="partner" t-value="ship_partner"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Products -->
|
||||
<table class="bordered">
|
||||
<!-- Ship details -->
|
||||
<table class="bordered fp-ps-info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 22%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 34%;">DESCRIPTION</th>
|
||||
<th style="width: 12%;">QTY</th>
|
||||
<th style="width: 10%;">UOM</th>
|
||||
<th style="width: 22%;">LOT / SERIAL</th>
|
||||
<th style="width: 33%;">
|
||||
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 33%;">
|
||||
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 34%;">
|
||||
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.move_ids_without_package" t-as="move">
|
||||
<tr>
|
||||
<t t-set="line" t-value="move.sale_line_id or move"/>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="int(move.quantity) if move.quantity == int(move.quantity) else move.quantity"/>
|
||||
</td>
|
||||
<td class="text-center"><span t-field="move.product_uom"/></td>
|
||||
<td>
|
||||
<t t-foreach="move.move_line_ids" t-as="ml">
|
||||
<t t-if="ml.lot_id">
|
||||
<span t-field="ml.lot_id.name"/><br/>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td><span t-esc="ship_via"/></td>
|
||||
<td>
|
||||
<t t-if="doc.scheduled_date">
|
||||
<span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</td>
|
||||
<td><span t-esc="tracking_text"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Items -->
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_items">
|
||||
<t t-set="w_ordered" t-value="'width: 8%;'"/>
|
||||
<t t-set="w_shipped" t-value="'width: 8%;'"/>
|
||||
<t t-set="w_bo" t-value="'width: 8%;'"/>
|
||||
<t t-set="w_part" t-value="'width: 17%;'"/>
|
||||
<t t-set="w_po" t-value="'width: 11%;'"/>
|
||||
<t t-set="w_wo" t-value="'width: 11%;'"/>
|
||||
<t t-set="w_process" t-value="'width: 14%;'"/>
|
||||
<t t-set="w_desc" t-value="'width: 23%;'"/>
|
||||
</t>
|
||||
|
||||
<!-- Notes -->
|
||||
<t t-if="doc.note">
|
||||
<div style="margin-top: 10px;">
|
||||
@@ -118,21 +319,8 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Sign off -->
|
||||
<div class="row" style="margin-top: 30px;">
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<div class="sig-line"/>
|
||||
<div class="small-muted">Shipper (Signature / Date)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<div class="sig-line"/>
|
||||
<div class="small-muted">Receiver (Signature / Date)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sign-off + QR -->
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,113 +337,112 @@
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
|
||||
<div class="fp-landscape">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
|
||||
|
||||
<t t-set="bill_partner" t-value="(doc.sale_id.partner_invoice_id if doc.sale_id and doc.sale_id.partner_invoice_id else (doc.partner_id.commercial_partner_id or doc.partner_id))"/>
|
||||
<t t-set="ship_partner" t-value="doc.partner_id"/>
|
||||
<t t-set="has_carrier" t-value="'carrier_id' in doc._fields and doc.carrier_id"/>
|
||||
<t t-set="ship_via" t-value="(doc.carrier_id.name if has_carrier else (doc.sale_id.x_fc_ship_via if doc.sale_id and 'x_fc_ship_via' in doc.sale_id._fields and doc.sale_id.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
|
||||
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
|
||||
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
|
||||
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
|
||||
|
||||
<!-- See portrait template for the numbering logic. -->
|
||||
<t t-set="so_name_raw" t-value="doc.sale_id.name if doc.sale_id else doc.name"/>
|
||||
<t t-set="ps_base_num" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
|
||||
<t t-set="outbound_pickings" t-value="doc.sale_id.picking_ids.filtered(lambda p: p.picking_type_id and p.picking_type_id.code == 'outgoing') if doc.sale_id else doc"/>
|
||||
<t t-set="picking_position" t-value="(sorted(outbound_pickings.ids).index(doc.id) + 1) if (doc.sale_id and doc.id in outbound_pickings.ids) else 1"/>
|
||||
<t t-set="ps_number" t-value="ps_base_num if picking_position <= 1 else ('%s-%02d' % (ps_base_num, picking_position))"/>
|
||||
|
||||
<!-- Code128 barcode encoding the packing-slip number so
|
||||
the printed label matches the visible title. Inlined
|
||||
via barcode_data_uri (no /report/barcode/ HTTP fetch
|
||||
— wkhtmltopdf network calls fail on entech). -->
|
||||
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
|
||||
|
||||
<t t-set="qr_payload" t-value="doc.name or ''"/>
|
||||
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
|
||||
|
||||
<div class="fp-landscape fp-ps">
|
||||
<div class="page">
|
||||
|
||||
<h2 style="text-align: left;">
|
||||
Packing Slip #
|
||||
<span t-field="doc.name"/>
|
||||
</h2>
|
||||
<div class="fp-ps-titlebar">
|
||||
<t t-if="ps_barcode_uri">
|
||||
<div class="fp-ps-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
|
||||
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-ps-title-en">
|
||||
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
|
||||
</span>
|
||||
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
|
||||
</div>
|
||||
|
||||
<!-- From / To -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">FROM</th>
|
||||
<th style="width: 50%;">SHIP TO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Bill To / Ship To -->
|
||||
<table class="bordered fp-ps-addrtable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="height: 80px; font-size: 12pt;">
|
||||
<strong><span t-field="doc.company_id.name"/></strong><br/>
|
||||
<div t-field="doc.company_id.partner_id"
|
||||
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
|
||||
</td>
|
||||
<td style="height: 80px; font-size: 12pt;">
|
||||
<strong><span t-field="doc.partner_id.name"/></strong><br/>
|
||||
<div t-field="doc.partner_id"
|
||||
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Shipment info -->
|
||||
<table class="bordered info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SHIP DATE</th>
|
||||
<th>SOURCE</th>
|
||||
<th>OPERATION</th>
|
||||
<th>CARRIER</th>
|
||||
<th>TRACKING REF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-esc="doc.origin or '-'"/></td>
|
||||
<td class="text-center"><span t-field="doc.picking_type_id"/></td>
|
||||
<td class="text-center">
|
||||
<t t-if="'carrier_id' in doc._fields and doc.carrier_id">
|
||||
<span t-field="doc.carrier_id"/>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Bill To:'"/>
|
||||
<t t-set="partner" t-value="bill_partner"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-if="'carrier_tracking_ref' in doc._fields">
|
||||
<span t-esc="doc.carrier_tracking_ref or '-'"/>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Ship To:'"/>
|
||||
<t t-set="partner" t-value="ship_partner"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Products -->
|
||||
<table class="bordered">
|
||||
<!-- Ship details -->
|
||||
<table class="bordered fp-ps-info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 18%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 26%;">DESCRIPTION</th>
|
||||
<th style="width: 10%;">ORDERED</th>
|
||||
<th style="width: 10%;">DONE</th>
|
||||
<th style="width: 8%;">UOM</th>
|
||||
<th style="width: 14%;">LOT / SERIAL</th>
|
||||
<th style="width: 14%;">NOTES</th>
|
||||
<th style="width: 33%;">
|
||||
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 33%;">
|
||||
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 34%;">
|
||||
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.move_ids_without_package" t-as="move">
|
||||
<tr>
|
||||
<t t-set="line" t-value="move.sale_line_id or move"/>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="int(move.product_uom_qty) if move.product_uom_qty == int(move.product_uom_qty) else move.product_uom_qty"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span t-esc="int(move.quantity) if move.quantity == int(move.quantity) else move.quantity"/>
|
||||
</td>
|
||||
<td class="text-center"><span t-field="move.product_uom"/></td>
|
||||
<td>
|
||||
<t t-foreach="move.move_line_ids" t-as="ml">
|
||||
<t t-if="ml.lot_id">
|
||||
<span t-field="ml.lot_id.name"/><br/>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
<td/>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td><span t-esc="ship_via"/></td>
|
||||
<td>
|
||||
<t t-if="doc.scheduled_date">
|
||||
<span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</td>
|
||||
<td><span t-esc="tracking_text"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Items: landscape gets a touch more breathing room on
|
||||
the description / part columns. -->
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_items">
|
||||
<t t-set="w_ordered" t-value="'width: 7%;'"/>
|
||||
<t t-set="w_shipped" t-value="'width: 7%;'"/>
|
||||
<t t-set="w_bo" t-value="'width: 7%;'"/>
|
||||
<t t-set="w_part" t-value="'width: 16%;'"/>
|
||||
<t t-set="w_po" t-value="'width: 10%;'"/>
|
||||
<t t-set="w_wo" t-value="'width: 10%;'"/>
|
||||
<t t-set="w_process" t-value="'width: 13%;'"/>
|
||||
<t t-set="w_desc" t-value="'width: 30%;'"/>
|
||||
</t>
|
||||
|
||||
<!-- Notes -->
|
||||
<t t-if="doc.note">
|
||||
<div style="margin-top: 10px;">
|
||||
@@ -264,21 +451,8 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Sign off -->
|
||||
<div class="row" style="margin-top: 30px;">
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<div class="sig-line"/>
|
||||
<div class="small-muted">Shipper (Signature / Date)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="sig-box">
|
||||
<div class="sig-line"/>
|
||||
<div class="small-muted">Receiver (Signature / Date)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sign-off + QR -->
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,28 +11,233 @@
|
||||
<!-- ============================================================= -->
|
||||
<!-- PORTRAIT -->
|
||||
<!-- ============================================================= -->
|
||||
<!-- Shared bilingual-label snippet. CSS class `.fp-bl` does the
|
||||
two-line render: English on top, French underneath in a lighter
|
||||
italic. Stored next to the report's own scss-style block so it
|
||||
doesn't drift when the same idiom propagates to other reports.
|
||||
|
||||
Title sizing: the previous attempt at "compact" (negative
|
||||
margin-top) pushed the title up INTO the wkhtmltopdf header zone
|
||||
(the company logo band) and clipped the top of the H1 glyphs.
|
||||
External_layout already places the page body at the bottom of
|
||||
the reserved margin-top — don't fight that. Use a small positive
|
||||
gap and shrink the title text instead. -->
|
||||
<!-- Custom minimal layout — same .article wrapper that Odoo's
|
||||
report pipeline expects (so UTF-8 charset handling works
|
||||
correctly via the standard processing path), but with NO
|
||||
auto company .header div. Includes a minimal .footer div
|
||||
that ONLY carries the wkhtmltopdf page-number placeholders
|
||||
(`<span class="page"/> / <span class="topage"/>`) — those
|
||||
only get filled in when the .footer div is extracted into
|
||||
wkhtmltopdf's footer-html stream. The .footer is otherwise
|
||||
empty so no boilerplate company info shows. -->
|
||||
<template id="fp_external_layout_clean">
|
||||
<t t-if="not o" t-set="o" t-value="doc"/>
|
||||
<t t-if="not company">
|
||||
<t t-if="company_id">
|
||||
<t t-set="company" t-value="company_id"/>
|
||||
</t>
|
||||
<t t-elif="o and 'company_id' in o and o.company_id.sudo()">
|
||||
<t t-set="company" t-value="o.company_id.sudo()"/>
|
||||
</t>
|
||||
<t t-else="else">
|
||||
<t t-set="company" t-value="res_company"/>
|
||||
</t>
|
||||
</t>
|
||||
<div class="article o_report_layout_standard"
|
||||
t-att-data-oe-model="o and o._name"
|
||||
t-att-data-oe-id="o and o.id"
|
||||
t-att-data-oe-lang="o and o.env.context.get('lang')">
|
||||
<t t-out="0"/>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div style="font-size: 9pt; color: #666; overflow: hidden;">
|
||||
<!-- Internal form code (e.g. "FRM-006") on the left.
|
||||
Each report sets `form_code` via t-set BEFORE the
|
||||
t-call to this layout; reports that don't set it
|
||||
leave the left side blank. -->
|
||||
<span style="float: left;" t-if="form_code"><t t-esc="form_code"/></span>
|
||||
<span style="float: right; white-space: nowrap; padding-left: 10px;" t-if="report_type == 'pdf'">Page <span class="page"/> / <span class="topage"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="fp_sale_bilingual_styles">
|
||||
<style>
|
||||
/* Inline bilingual: English bold, then a faint slash, then
|
||||
French italic-grey. Sits on one line where room allows
|
||||
and wraps to two naturally if the cell is narrow. Apply
|
||||
this everywhere except super-narrow cells (QTY, UOM)
|
||||
where the cell is physically too tight even for the
|
||||
shortest French word — those use the stacked variant
|
||||
below. */
|
||||
.fp-bl-en { font-weight: bold; }
|
||||
.fp-bl-sep { color: #999; margin: 0 3px; font-weight: normal; }
|
||||
.fp-bl-fr { font-weight: normal; font-style: italic; color: #555; }
|
||||
/* Stacked variant for narrow cells — EN on top line, FR
|
||||
below in italic-grey. */
|
||||
.fp-bl-en-stk { display: block; font-weight: bold; }
|
||||
.fp-bl-fr-stk { display: block; font-weight: normal; font-style: italic; color: #555; font-size: 80%; margin-top: 1px; }
|
||||
/* This template uses fp_external_layout_clean (sibling
|
||||
template in this file) instead of web.external_layout.
|
||||
That gives us the `.article` wrapper Odoo's report
|
||||
renderer needs for proper UTF-8 dispatch, WITHOUT the
|
||||
`.header` / `.footer` divs that wkhtmltopdf would
|
||||
extract into page-margin streams. So the body owns the
|
||||
entire visible header (logo + address LEFT, title +
|
||||
barcode RIGHT) and no auto company band shows up. See
|
||||
CLAUDE.md "Custom-header reports need .article wrapper". */
|
||||
.fp-report.fp-sale { padding-top: 0; }
|
||||
|
||||
/* Custom inline header: 2-column flex-via-floats. Left has
|
||||
the company logo + address + phone + URL; right has the
|
||||
document title (bilingual stack) + Code128 barcode. */
|
||||
.fp-sale-header-row { overflow: hidden; margin-bottom: 14px; padding-bottom: 6px; }
|
||||
.fp-sale-header-left { float: left; width: 38%; }
|
||||
/* Middle column: NADCAP accreditation logo (pulled from
|
||||
company settings) above a small "25 Years in Business"
|
||||
medallion. Conditional on company.x_fc_nadcap_active so
|
||||
non-Nadcap shops only see the badge. */
|
||||
.fp-sale-header-mid { float: left; width: 24%; text-align: center; padding-top: 4px; }
|
||||
.fp-nadcap-logo { max-height: 45px; max-width: 115px; display: inline-block; }
|
||||
/* 25-Years-in-Business medallion: tasteful gold-on-cream
|
||||
bordered badge. Picks a dark muted gold (#8a6d2c) so it
|
||||
reads as "anniversary keepsake" rather than gaudy. */
|
||||
.fp-25-badge {
|
||||
display: inline-block;
|
||||
border: 1.5px solid #c8a55b;
|
||||
background-color: #faf5e8;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.fp-25-badge .fp-25-num { font-size: 22pt; font-weight: bold; color: #8a6d2c; }
|
||||
.fp-25-badge .fp-25-lbl { font-size: 7pt; font-weight: bold; color: #8a6d2c; letter-spacing: 1.2px; margin-top: 1px; }
|
||||
.fp-25-badge .fp-25-sub { font-size: 6.5pt; color: #8a6d2c; font-style: italic; margin-top: 1px; }
|
||||
/* Right column: title (English bold + French italic) + barcode
|
||||
+ SO number all centered as a stacked block. Width reduced
|
||||
to 38% to share row with the new middle NADCAP+25Years cell. */
|
||||
.fp-sale-header-right { float: right; width: 38%; text-align: center; }
|
||||
.fp-sale-logo { max-height: 50px; max-width: 280px; display: block; margin-bottom: 4px; }
|
||||
.fp-sale-company-addr { font-size: 8.5pt; color: #222; line-height: 1.35; }
|
||||
.fp-sale-company-addr div { margin: 0; }
|
||||
.fp-sale-company-addr a { color: #2e6da4; text-decoration: none; }
|
||||
|
||||
/* Inline footer line — phone | email | website | tax id.
|
||||
One-time render at the bottom of page 1 (multi-page SO
|
||||
reports are rare; if we ever need it, switch to
|
||||
wkhtmltopdf --footer-html). */
|
||||
.fp-sale-customfooter { text-align: center; font-size: 8pt; color: #666; margin-top: 24px; padding-top: 8px; border-top: 1px solid #ccc; }
|
||||
/* Title bar uses float-based div layout, NOT an HTML table —
|
||||
the global ".fp-report table" rule was applying borders
|
||||
to every nested table even with "border: 0 !important",
|
||||
so the only reliable fix is to avoid the table element. */
|
||||
.fp-sale-titlebar { margin: 0 0 10px 0; padding: 0; overflow: hidden; }
|
||||
/* Stacked title: English bold/large on top, French italic/
|
||||
grey below. Sizes picked so the French line is roughly
|
||||
the same horizontal width as the English line (English
|
||||
is shorter character-wise but rendered larger). */
|
||||
.fp-sale-title-en { font-size: 18pt; font-weight: bold; color: #2e2e2e; line-height: 1.1; display: block; }
|
||||
.fp-sale-title-fr { font-size: 13pt; font-style: italic; color: #555; line-height: 1.1; display: block; margin-top: 2px; }
|
||||
.fp-sale-title-num { font-weight: bold; margin-left: 6px; }
|
||||
/* Barcode: bigger so it scans reliably. Wrap the img + label
|
||||
in an inline-block so the label centers under the barcode
|
||||
(not under the full floated column). Explicit no-border on
|
||||
the img (wkhtmltopdf adds a 1px frame to inline-data img
|
||||
elements by default on entech). */
|
||||
.fp-sale-barcode { float: right; margin-left: 12px; }
|
||||
.fp-bc-wrap { display: inline-block; text-align: center; }
|
||||
.fp-bc-wrap img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
|
||||
.fp-bc-wrap .fp-bc-label { font-size: 16pt; font-weight: bold; color: #000; margin-top: 6px; letter-spacing: 1.5px; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<template id="report_fp_sale_portrait">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<div class="fp-report">
|
||||
<div class="page">
|
||||
<!-- Internal form code rendered on the footer left side
|
||||
by fp_external_layout_clean (see that template). -->
|
||||
<t t-set="form_code" t-value="'FRM-006'"/>
|
||||
<t t-call="fusion_plating_reports.fp_external_layout_clean">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id or env.company"/>
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
|
||||
|
||||
<!-- Title -->
|
||||
<h4>
|
||||
<span t-if="doc.state in ['draft','sent']">Quotation # </span>
|
||||
<span t-else="">Sales Order # </span>
|
||||
<span t-field="doc.name"/>
|
||||
</h4>
|
||||
<!-- Compute helpers -->
|
||||
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
|
||||
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
|
||||
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
|
||||
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
|
||||
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
|
||||
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
|
||||
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
|
||||
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
|
||||
|
||||
<!-- Billing / Shipping -->
|
||||
<div class="fp-report fp-sale">
|
||||
<!-- Inline header (drops web.external_layout for this
|
||||
report — see CSS comment for context). Left: logo
|
||||
+ address + tel/fax + URL. Right: bilingual title
|
||||
+ Code128 barcode of the order number. -->
|
||||
<div class="fp-sale-header-row">
|
||||
<div class="fp-sale-header-left">
|
||||
<t t-if="logo_uri">
|
||||
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
|
||||
</t>
|
||||
<div class="fp-sale-company-addr">
|
||||
<div>
|
||||
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
|
||||
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
|
||||
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
|
||||
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
|
||||
</div>
|
||||
<div t-if="company.phone or company_fax">
|
||||
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
|
||||
<t t-if="company.phone and company_fax">   </t>
|
||||
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
|
||||
</div>
|
||||
<div t-if="company.partner_id.website">
|
||||
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Middle column: NADCAP logo only. Base64-inlined
|
||||
from `company.x_fc_nadcap_logo` so wkhtmltopdf
|
||||
doesn't have to fetch over HTTP (network calls
|
||||
fail on entech). -->
|
||||
<div class="fp-sale-header-mid">
|
||||
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
|
||||
<img class="fp-nadcap-logo"
|
||||
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
|
||||
alt="Nadcap Accredited"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="fp-sale-header-right">
|
||||
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
|
||||
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
|
||||
<t t-if="barcode_uri">
|
||||
<div class="fp-bc-wrap" style="margin-top: 4px;">
|
||||
<img t-att-src="barcode_uri" alt="Order Barcode"/>
|
||||
<div class="fp-bc-label"><span t-field="doc.name"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Billing / Shipping (wide cells — inline) -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">BILLING ADDRESS</th>
|
||||
<th style="width: 50%;">SHIPPING ADDRESS</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
|
||||
</th>
|
||||
<th style="width: 50%;">
|
||||
<span class="fp-bl-en">Shipping Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse d'expédition</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -49,117 +254,175 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Order info -->
|
||||
<!-- Row 1: 5 narrow cells (20% each) — stacked
|
||||
so the French label doesn't overflow into
|
||||
the next column. -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 20%;">ORDER DATE</th>
|
||||
<th class="info-header" style="width: 20%;">EXPIRATION</th>
|
||||
<th class="info-header" style="width: 20%;">SALESPERSON</th>
|
||||
<th class="info-header" style="width: 20%;">CUSTOMER PO #</th>
|
||||
<th class="info-header" style="width: 20%;">RUSH</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Order Date</span>
|
||||
<span class="fp-bl-fr-stk">Date de commande</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Delivery Date</span>
|
||||
<span class="fp-bl-fr-stk">Date de livraison</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 20%;">
|
||||
<span class="fp-bl-en-stk">Salesperson</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">Lead Time</span>
|
||||
<span class="fp-bl-fr-stk">Délai</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.date_order" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-field="doc.validity_date"/></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-if="doc.x_fc_rush_order" class="status-warning">RUSH</span>
|
||||
<span t-else="">Standard</span>
|
||||
<t t-if="doc.commitment_date">
|
||||
<span t-field="doc.commitment_date" t-options="{'widget': 'date'}"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</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">
|
||||
<!-- Lead Time renders from the
|
||||
computed display string on
|
||||
sale.order. Rush stays
|
||||
highlighted; everything
|
||||
else (range / single value
|
||||
/ Standard) is plain text. -->
|
||||
<t t-if="doc.x_fc_lead_time_display == 'Rush'">
|
||||
<span class="status-warning">Rush</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="doc.x_fc_lead_time_display"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Plating info -->
|
||||
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id or doc.x_fc_delivery_method">
|
||||
<!-- Row 2: 3 wider cells (33% each) — inline. -->
|
||||
<t t-if="doc.x_fc_customer_job_number or spec_label or delivery_method_label">
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 34%;">PART</th>
|
||||
<th class="info-header" style="width: 33%;">SPECIFICATION</th>
|
||||
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
|
||||
<th class="info-header" style="width: 34%;">
|
||||
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 33%;">
|
||||
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
|
||||
</th>
|
||||
<th class="info-header" style="width: 33%;">
|
||||
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
|
||||
<td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
|
||||
<td class="text-center">
|
||||
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
|
||||
<span t-esc="dm"/>
|
||||
</td>
|
||||
<td class="text-center"><span t-esc="doc.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>
|
||||
|
||||
<!-- Scheduling + customer job reference -->
|
||||
<t t-if="doc.x_fc_customer_job_number or doc.x_fc_planned_start_date or doc.commitment_date or doc.x_fc_ship_via">
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="info-header" style="width: 25%;">CUSTOMER JOB #</th>
|
||||
<th class="info-header" style="width: 25%;">PLANNED START</th>
|
||||
<th class="info-header" style="width: 25%;">CUSTOMER DEADLINE</th>
|
||||
<th class="info-header" style="width: 25%;">SHIP VIA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center"><span t-esc="doc.x_fc_customer_job_number or '-'"/></td>
|
||||
<td class="text-center"><span t-field="doc.x_fc_planned_start_date"/></td>
|
||||
<td class="text-center"><span t-field="doc.commitment_date"/></td>
|
||||
<td class="text-center"><span t-esc="doc.x_fc_ship_via or '-'"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Blanket / block-partial callout (confirmed-order shipping flags) -->
|
||||
<!-- Blanket / block-partial callout -->
|
||||
<t t-if="doc.x_fc_is_blanket_order or doc.x_fc_block_partial_shipments">
|
||||
<div class="highlight-box">
|
||||
<t t-if="doc.x_fc_is_blanket_order">
|
||||
<strong>Blanket Order.</strong>
|
||||
<strong>Blanket Order / Commande ouverte.</strong>
|
||||
Parts will be released in quantities over time.
|
||||
</t>
|
||||
<t t-if="doc.x_fc_block_partial_shipments">
|
||||
<strong>Partial shipments blocked.</strong>
|
||||
<strong>Partial shipments blocked / Expéditions partielles bloquées.</strong>
|
||||
The order ships as one complete batch.
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Order lines -->
|
||||
<!-- Order lines. Taxes column dropped — taxes
|
||||
summarized in the totals block below; per-line
|
||||
tax labels were noise on a single-tax-region
|
||||
plating order. The part-number cell appends
|
||||
the catalog `name` (Part Name) after the
|
||||
revision so customers see PN + Rev + Name. -->
|
||||
<table class="bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 20%;">PART NUMBER</th>
|
||||
<th class="text-start" style="width: 30%;">DESCRIPTION</th>
|
||||
<th style="width: 8%;">QTY</th>
|
||||
<th style="width: 8%;">UOM</th>
|
||||
<th style="width: 12%;">UNIT PRICE</th>
|
||||
<th style="width: 10%;">TAXES</th>
|
||||
<th style="width: 12%;">AMOUNT</th>
|
||||
<th class="text-start" style="width: 24%;">
|
||||
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th class="text-start" style="width: 38%;">
|
||||
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
|
||||
</th>
|
||||
<th style="width: 8%;">
|
||||
<span class="fp-bl-en-stk">Qty</span>
|
||||
<span class="fp-bl-fr-stk">Qté</span>
|
||||
</th>
|
||||
<th style="width: 8%;">
|
||||
<span class="fp-bl-en-stk">UOM</span>
|
||||
<span class="fp-bl-fr-stk">UDM</span>
|
||||
</th>
|
||||
<th style="width: 11%;">
|
||||
<span class="fp-bl-en-stk">Unit Price</span>
|
||||
<span class="fp-bl-fr-stk">Prix unitaire</span>
|
||||
</th>
|
||||
<th style="width: 11%;">
|
||||
<span class="fp-bl-en-stk">Amount</span>
|
||||
<span class="fp-bl-fr-stk">Montant</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.order_line" t-as="line">
|
||||
<t t-if="line.display_type == 'line_section'">
|
||||
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
|
||||
<tr class="section-row"><td colspan="6"><strong t-field="line.name"/></td></tr>
|
||||
</t>
|
||||
<t t-elif="line.display_type == 'line_note'">
|
||||
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
|
||||
<tr class="note-row"><td colspan="6"><span t-field="line.name"/></td></tr>
|
||||
</t>
|
||||
<t t-elif="not line.display_type or line.display_type == 'product'">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
<!-- Three stacked lines:
|
||||
1. Part Number (catalog part_number + revision)
|
||||
2. Name (catalog name, falls back to em-dash)
|
||||
3. Serial Number (m2m serials joined, or em-dash) -->
|
||||
<div>
|
||||
<strong>Part #:</strong>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Name:</strong>
|
||||
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
|
||||
<span t-esc="line.x_fc_part_catalog_id.name"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
<div>
|
||||
<strong>S/N:</strong>
|
||||
<t t-if="line.x_fc_serial_ids">
|
||||
<span t-esc="', '.join(line.x_fc_serial_ids.mapped('name'))"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Rebind `line` with fp_no_serial_in_desc=True so the
|
||||
shared description macro skips its Serial line — we
|
||||
already render S/N in the part-number cell above. -->
|
||||
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@@ -169,9 +432,6 @@
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
@@ -184,37 +444,38 @@
|
||||
<!-- Terms + Totals -->
|
||||
<div class="row" style="margin-top: 15px;">
|
||||
<div class="col-6">
|
||||
<t t-if="doc.payment_term_id.note">
|
||||
<strong>Payment Terms:</strong><br/>
|
||||
<span t-field="doc.payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-if="doc.x_fc_invoice_strategy">
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Invoice Strategy: </strong>
|
||||
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
|
||||
<span t-esc="inv_strat"/>
|
||||
<t t-if="doc.x_fc_invoice_strategy == 'deposit' and doc.x_fc_deposit_percent">
|
||||
(<span t-esc="doc.x_fc_deposit_percent"/>%)
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="doc.payment_term_id">
|
||||
<strong>Payment Terms / Modalités de paiement:</strong><br/>
|
||||
<t t-if="doc.payment_term_id.note">
|
||||
<span t-field="doc.payment_term_id.note"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-field="doc.payment_term_id.name"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<div class="col-6" style="text-align: right;">
|
||||
<table class="totals-table" style="width: auto; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="min-width: 150px;">Subtotal</td>
|
||||
<td style="min-width: 150px;">
|
||||
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
|
||||
</td>
|
||||
<td class="text-end" style="min-width: 110px;">
|
||||
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Taxes</td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="background-color: #c1c1c1;">
|
||||
<td><strong>Grand Total</strong></td>
|
||||
<td>
|
||||
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
|
||||
</td>
|
||||
<td class="text-end"><strong>
|
||||
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
|
||||
</strong></td>
|
||||
@@ -226,7 +487,7 @@
|
||||
<!-- External (customer-visible) notes -->
|
||||
<t t-if="doc.x_fc_external_note">
|
||||
<div style="margin-top: 15px;">
|
||||
<strong>Notes:</strong>
|
||||
<strong>Notes / Remarques:</strong>
|
||||
<div t-field="doc.x_fc_external_note"/>
|
||||
</div>
|
||||
</t>
|
||||
@@ -234,7 +495,7 @@
|
||||
<!-- Terms and Conditions -->
|
||||
<t t-if="doc.note">
|
||||
<div style="margin-top: 15px;">
|
||||
<strong>Terms and Conditions:</strong>
|
||||
<strong>Terms and Conditions / Conditions générales:</strong>
|
||||
<div t-field="doc.note"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.26.2.0',
|
||||
'version': '19.0.30.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -45,7 +45,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_sequence_data.xml',
|
||||
'data/fp_cron_data.xml',
|
||||
'data/fp_tablet_config_data.xml',
|
||||
'views/fp_shopfloor_station_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/fp_bake_oven_views.xml',
|
||||
'views/fp_bake_window_views.xml',
|
||||
'views/fp_first_piece_gate_views.xml',
|
||||
@@ -62,6 +64,32 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
||||
# so tokens are resolved via bundle concatenation order).
|
||||
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
|
||||
# ---- Shared OWL services (Phase 1 — tablet redesign) ----
|
||||
# Registered ONCE in web.assets_backend; Odoo 19 auto-compiles
|
||||
# into BOTH bright and dark bundles via $o-webclient-color-scheme.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/workflow_chip.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/gate_viz.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
||||
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
|
||||
# ---- 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/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
|
||||
@@ -6,3 +6,6 @@ from . import shopfloor_controller
|
||||
from . import manager_controller
|
||||
from . import tank_status
|
||||
from . import move_controller
|
||||
from . import workspace_controller
|
||||
from . import landing_controller
|
||||
from . import tablet_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 '',
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import http
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
@@ -466,3 +466,231 @@ class FpManagerDashboardController(http.Controller):
|
||||
),
|
||||
)
|
||||
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
|
||||
# ----------------------------------------------------------------------
|
||||
# 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')
|
||||
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
|
||||
keep the legacy `_wo` suffix where they were referenced from the
|
||||
XML so the template doesn't need to be rewritten — internally
|
||||
these now point at fp.job.step rows.
|
||||
New consumers should use /fp/landing/kanban via the
|
||||
fp_shopfloor_landing client action (Phase 3 tablet redesign).
|
||||
"""
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s — "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
env = request.env
|
||||
user = env.user
|
||||
|
||||
@@ -1002,8 +1013,20 @@ class FpShopfloorController(http.Controller):
|
||||
# ----------------------------------------------------------------------
|
||||
# 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')
|
||||
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')
|
||||
if Queue is None or not hasattr(Queue, 'build_for_user'):
|
||||
# Fallback: synthesize the queue directly from fp.job.step.
|
||||
@@ -1093,14 +1116,26 @@ class FpShopfloorController(http.Controller):
|
||||
|
||||
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')
|
||||
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 /
|
||||
paused state. Columns are fp.work.centre rows; an "Unassigned"
|
||||
pseudo-column collects steps without a work centre.
|
||||
New consumers should use /fp/landing/kanban with mode='all_plant'
|
||||
via the fp_shopfloor_landing client action (Phase 3 tablet
|
||||
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
|
||||
search = (search or '').strip().lower()
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# -*- 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 tablet PIN gate (Phase 6 tablet redesign).
|
||||
|
||||
POST /fp/tablet/tiles — list of tiles for the lock screen
|
||||
POST /fp/tablet/unlock — verify PIN + clear/increment failure counter
|
||||
POST /fp/tablet/set_pin — self-service set/change PIN
|
||||
POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN
|
||||
POST /fp/tablet/ping — bump server-side last-active timestamp
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, fields, http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_manager(env):
|
||||
"""True if calling user is in the fusion_plating manager group."""
|
||||
return env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||||
|
||||
|
||||
class FpTabletController(http.Controller):
|
||||
"""Tablet PIN gate endpoints. All require an authenticated Odoo
|
||||
session (the tablet logs in once as a 'shopfloor service' user).
|
||||
"""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/set_pin — self-service set or change
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user')
|
||||
def set_pin(self, new_pin, old_pin=None):
|
||||
env = request.env
|
||||
user = env.user
|
||||
existing_hash = user.sudo().x_fc_tablet_pin_hash
|
||||
if existing_hash:
|
||||
if not old_pin:
|
||||
return {'ok': False, 'error': _('Current PIN is required to change it.')}
|
||||
if not user.verify_tablet_pin(old_pin):
|
||||
return {'ok': False, 'error': _('Current PIN is incorrect.')}
|
||||
try:
|
||||
user.set_tablet_pin(new_pin)
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
_logger.info(
|
||||
"Tablet PIN set/changed for uid %s by self", user.id,
|
||||
)
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/reset_pin_for — manager-only
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user')
|
||||
def reset_pin_for(self, user_id):
|
||||
env = request.env
|
||||
if not _is_manager(env):
|
||||
_logger.warning(
|
||||
"Non-manager uid %s attempted to reset PIN for user %s",
|
||||
env.uid, user_id,
|
||||
)
|
||||
return {'ok': False, 'error': _('Manager privilege required.')}
|
||||
target = env['res.users'].browse(int(user_id))
|
||||
if not target.exists():
|
||||
return {'ok': False, 'error': _('User not found.')}
|
||||
target.clear_tablet_pin()
|
||||
_logger.info(
|
||||
"Tablet PIN reset for uid %s by manager uid %s",
|
||||
target.id, env.uid,
|
||||
)
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/unlock — verify PIN + manage failure counter / lockout
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/unlock', type='jsonrpc', auth='user')
|
||||
def unlock(self, user_id, pin):
|
||||
env = request.env
|
||||
Users = env['res.users'].sudo() # need sudo to read hash field
|
||||
target = Users.browse(int(user_id))
|
||||
if not target.exists():
|
||||
return {'ok': False, 'error': _('User not found.')}
|
||||
|
||||
# No PIN set yet — caller must set one first
|
||||
if not target.x_fc_tablet_pin_hash:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('No PIN set. Set one in Preferences first.'),
|
||||
'needs_setup': True,
|
||||
}
|
||||
|
||||
# Currently locked out?
|
||||
now = fields.Datetime.now()
|
||||
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Account locked. Try again in a few minutes.'),
|
||||
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
|
||||
}
|
||||
|
||||
if target.verify_tablet_pin(pin):
|
||||
# Reset failure state on success
|
||||
target.write({
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
_logger.info(
|
||||
"Tablet unlocked by uid %s (session uid %s)",
|
||||
target.id, env.uid,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'current_tech_id': target.id,
|
||||
'current_tech_name': target.name,
|
||||
}
|
||||
|
||||
# Wrong PIN — increment and check threshold
|
||||
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
|
||||
threshold = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_threshold', 5,
|
||||
))
|
||||
lockout_min = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5,
|
||||
))
|
||||
vals = {'x_fc_tablet_pin_failed_count': new_count}
|
||||
if new_count >= threshold:
|
||||
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
|
||||
target.write(vals)
|
||||
_logger.warning(
|
||||
"Tablet PIN failure for uid %s (count=%d, locked=%s)",
|
||||
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
|
||||
)
|
||||
if vals.get('x_fc_tablet_locked_until'):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min,
|
||||
'locked_until': vals['x_fc_tablet_locked_until'].isoformat(),
|
||||
}
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Incorrect PIN.'),
|
||||
'attempts_remaining': threshold - new_count,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/tiles — lock-screen tile grid
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
|
||||
def tiles(self, station_id=None):
|
||||
env = request.env
|
||||
op_group = env.ref(
|
||||
'fusion_plating.group_fusion_plating_operator',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not op_group:
|
||||
return {'ok': False, 'error': 'operator group missing'}
|
||||
|
||||
# Determine candidate users — station roster wins if non-empty
|
||||
users = op_group.user_ids
|
||||
if station_id:
|
||||
Station = env['fusion.plating.shopfloor.station']
|
||||
station = Station.browse(int(station_id))
|
||||
if (station.exists()
|
||||
and 'x_fc_authorised_user_ids' in station._fields
|
||||
and station.x_fc_authorised_user_ids):
|
||||
users = station.x_fc_authorised_user_ids
|
||||
|
||||
# has_pin needs sudo-read on the hash field
|
||||
clocked_ids = set()
|
||||
if 'hr.employee' in env and hasattr(
|
||||
env['hr.employee'], '_fp_clocked_in_user_ids',
|
||||
):
|
||||
clocked_ids = env['hr.employee']._fp_clocked_in_user_ids() or set()
|
||||
|
||||
users_sorted = users.sorted('name')
|
||||
users_sudo = users_sorted.sudo()
|
||||
tiles = []
|
||||
for u, u_sudo in zip(users_sorted, users_sudo):
|
||||
tiles.append({
|
||||
'user_id': u.id,
|
||||
'name': u.name,
|
||||
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
|
||||
'is_clocked_in': u.id in clocked_ids,
|
||||
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
|
||||
})
|
||||
# Clocked-in first, then alphabetical within bucket
|
||||
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
|
||||
return {'ok': True, 'tiles': tiles}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/ping — heartbeat used by the OWL component on every action
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/ping', type='jsonrpc', auth='user')
|
||||
def ping(self, current_tech_id=None):
|
||||
"""Lightweight heartbeat. Used by the OWL component to confirm
|
||||
the server-side session is alive AND to log the tech-of-record
|
||||
every few minutes so the server has forensic visibility into
|
||||
which tech was 'driving' the tablet at any moment.
|
||||
"""
|
||||
if current_tech_id:
|
||||
_logger.debug(
|
||||
"Tablet ping: session uid %s carrying tablet_tech_id=%s",
|
||||
request.env.uid, current_tech_id,
|
||||
)
|
||||
return {'ok': True, 'server_time': fields.Datetime.now().isoformat()}
|
||||
@@ -0,0 +1,338 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoints for the Job Workspace client action.
|
||||
|
||||
Surfaces a single fp.job + step list + workflow milestones + side-panel
|
||||
data (spec PDF, attachments, chatter) + action endpoints (hold, sign-off,
|
||||
milestone advance).
|
||||
|
||||
Endpoints:
|
||||
POST /fp/workspace/load — full payload for one fp.job
|
||||
POST /fp/workspace/hold — create quality.hold with photo
|
||||
POST /fp/workspace/sign_off — capture signature + finish step
|
||||
POST /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
|
||||
Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpWorkspaceController(http.Controller):
|
||||
"""JSON-RPC endpoints for the JobWorkspace OWL client action."""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/load — full workspace payload
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/load', type='jsonrpc', auth='user')
|
||||
def load(self, job_id):
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
_logger.warning("workspace/load: job %s not found", job_id)
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
|
||||
# ---- Workflow milestones ----------------------------------------
|
||||
all_states = env['fp.job.workflow.state'].search([], order='sequence, id')
|
||||
current = job.workflow_state_id
|
||||
passed_ids = set()
|
||||
for ws in all_states:
|
||||
passed_ids.add(ws.id)
|
||||
if ws.id == current.id:
|
||||
break
|
||||
workflow_states = [{
|
||||
'id': ws.id,
|
||||
'name': ws.name,
|
||||
'color': ws.color or 'grey',
|
||||
'sequence': ws.sequence or 0,
|
||||
'passed': ws.id in passed_ids,
|
||||
'is_current': ws.id == current.id,
|
||||
} for ws in all_states]
|
||||
|
||||
# ---- Steps ------------------------------------------------------
|
||||
steps = []
|
||||
for step in job.step_ids.sorted('sequence'):
|
||||
override = job.override_ids.filtered(
|
||||
lambda o, n=step.recipe_node_id: o.node_id.id == n.id
|
||||
) if 'override_ids' in job._fields else env['fp.job.node.override']
|
||||
steps.append({
|
||||
'id': step.id,
|
||||
'sequence': step.sequence,
|
||||
'sequence_display': (step.sequence or 0) // 10,
|
||||
'name': step.name or '',
|
||||
'kind': step.kind or 'other',
|
||||
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
|
||||
'state': step.state,
|
||||
'assigned_user_id': step.assigned_user_id.id or False,
|
||||
'assigned_user_name': step.assigned_user_id.name or '',
|
||||
'work_centre_name': step.work_centre_id.name or '',
|
||||
'duration_actual': step.duration_actual or 0,
|
||||
'duration_expected': step.duration_expected or 0,
|
||||
'date_started_iso': fp_format(
|
||||
env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
|
||||
) if step.date_started else '',
|
||||
'instructions': step.instructions or '',
|
||||
'thickness_target': step.thickness_target or 0,
|
||||
'thickness_uom': step.thickness_uom or '',
|
||||
'dwell_time_minutes': step.dwell_time_minutes or 0,
|
||||
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
|
||||
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
|
||||
'can_start': bool(step.can_start) if 'can_start' in step._fields else (
|
||||
step.state in ('ready', 'paused') and step.blocker_kind == 'none'
|
||||
),
|
||||
'blocker_kind': step.blocker_kind,
|
||||
'blocker_reason': step.blocker_reason or '',
|
||||
'blocker_jump_target_model': step.blocker_jump_target_model or '',
|
||||
'blocker_jump_target_id': step.blocker_jump_target_id or 0,
|
||||
'override_excluded': bool(override and not override.included),
|
||||
'quick_look_prompt_count': len(
|
||||
getattr(step, 'quick_look_prompt_ids', step.browse())
|
||||
),
|
||||
})
|
||||
|
||||
# ---- Spec + attachments + chatter -------------------------------
|
||||
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else False
|
||||
attachments = env['ir.attachment'].search([
|
||||
('res_model', '=', 'fp.job'),
|
||||
('res_id', '=', job.id),
|
||||
], limit=20)
|
||||
chatter = job.message_ids.filtered(
|
||||
lambda m: m.message_type in ('comment', 'notification')
|
||||
).sorted('date', reverse=True)[:10]
|
||||
|
||||
# ---- Required cert state ----------------------------------------
|
||||
try:
|
||||
needs = list(job._resolve_required_cert_types())
|
||||
except Exception:
|
||||
needs = []
|
||||
try:
|
||||
has_draft = bool(job._fp_has_draft_required_certs())
|
||||
except Exception:
|
||||
has_draft = False
|
||||
required_certs = {'needs': needs, 'has_draft': has_draft}
|
||||
|
||||
# ---- Active step (the one in_progress) --------------------------
|
||||
active = (
|
||||
job.active_step_id
|
||||
if 'active_step_id' in job._fields and job.active_step_id
|
||||
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'job': {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'display_wo_name': job.display_wo_name,
|
||||
'partner_name': job.partner_id.name or '',
|
||||
'product_name': job.product_id.display_name or '',
|
||||
'part_number': (
|
||||
job.part_catalog_id.part_number
|
||||
if 'part_catalog_id' in job._fields and job.part_catalog_id
|
||||
else ''
|
||||
),
|
||||
'qty': int(job.qty or 0),
|
||||
'qty_done': int(job.qty_done or 0),
|
||||
'qty_scrapped': int(job.qty_scrapped or 0),
|
||||
'date_deadline': fp_format(
|
||||
env, job.date_deadline, fmt='%Y-%m-%d',
|
||||
) if job.date_deadline else '',
|
||||
'state': job.state,
|
||||
'workflow_state': {
|
||||
'id': current.id,
|
||||
'name': current.name,
|
||||
'color': current.color or 'grey',
|
||||
} if current else None,
|
||||
'next_milestone_action': job.next_milestone_action or '',
|
||||
'next_milestone_label': job.next_milestone_label or '',
|
||||
'quality_hold_count': job.quality_hold_count or 0,
|
||||
'priority': job.priority or 'normal',
|
||||
},
|
||||
'workflow_states': workflow_states,
|
||||
'steps': steps,
|
||||
'active_step_id': active.id if active else False,
|
||||
'spec': {
|
||||
'id': spec.id,
|
||||
'name': spec.name,
|
||||
} if spec else None,
|
||||
'attachments': [
|
||||
{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'mimetype': a.mimetype or '',
|
||||
'url': f'/web/content/{a.id}',
|
||||
}
|
||||
for a in attachments
|
||||
],
|
||||
'chatter': [
|
||||
{
|
||||
'id': m.id,
|
||||
'author': m.author_id.name or 'System',
|
||||
'body': m.body or '',
|
||||
'date': fp_format(env, m.date, fmt='%Y-%m-%d %H:%M') if m.date else '',
|
||||
}
|
||||
for m in chatter
|
||||
],
|
||||
'required_certs': required_certs,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/hold — create a quality.hold from HoldComposer
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
|
||||
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
|
||||
part_ref='', step_id=None, mark_for_scrap=False,
|
||||
photo_data=None, photo_filename=None):
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
if not qty_on_hold or int(qty_on_hold) < 1:
|
||||
return {'ok': False, 'error': 'qty_on_hold must be at least 1'}
|
||||
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
hold_vals = {
|
||||
'part_ref': part_ref or '',
|
||||
'qty_on_hold': int(qty_on_hold),
|
||||
'qty_original': int(job.qty or 0),
|
||||
'hold_reason': reason or 'other',
|
||||
'description': description or '',
|
||||
'mark_for_scrap': bool(mark_for_scrap),
|
||||
}
|
||||
if 'x_fc_job_id' in Hold._fields:
|
||||
hold_vals['x_fc_job_id'] = job.id
|
||||
if step_id and 'x_fc_step_id' in Hold._fields:
|
||||
hold_vals['x_fc_step_id'] = int(step_id)
|
||||
|
||||
try:
|
||||
hold = Hold.create(hold_vals)
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/hold: create failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# Attach photo if provided (base64 string from the camera input).
|
||||
# Photo attach failure does NOT roll back the hold — log + continue.
|
||||
attachment_id = False
|
||||
if photo_data:
|
||||
try:
|
||||
att = env['ir.attachment'].create({
|
||||
'name': photo_filename or f'hold_{hold.id}.png',
|
||||
'datas': photo_data,
|
||||
'res_model': 'fusion.plating.quality.hold',
|
||||
'res_id': hold.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
attachment_id = att.id
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/hold: photo attach failed for hold %s", hold.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Hold %s created on job %s by uid %s, reason %s, qty %s",
|
||||
hold.name, job.name, env.uid, reason, qty_on_hold,
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'hold_id': hold.id,
|
||||
'hold_name': hold.name,
|
||||
'state': hold.state,
|
||||
'attachment_id': attachment_id,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri):
|
||||
env = request.env
|
||||
sig = (signature_data_uri or '').strip()
|
||||
if not sig:
|
||||
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required to finish this step.',
|
||||
}
|
||||
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it)
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
|
||||
try:
|
||||
env['ir.attachment'].create({
|
||||
'name': f'signature_{step.id}.png',
|
||||
'datas': sig,
|
||||
'res_model': 'fp.job.step',
|
||||
'res_id': step.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/sign_off: attachment failed for step %s", step.id,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save signature.'}
|
||||
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/sign_off: button_finish failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
|
||||
return {
|
||||
'ok': True,
|
||||
'step_id': step.id,
|
||||
'state': step.state,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
|
||||
def advance_milestone(self, job_id):
|
||||
env = request.env
|
||||
job = env['fp.job'].browse(int(job_id))
|
||||
if not job.exists():
|
||||
return {'ok': False, 'error': f'Job {job_id} not found'}
|
||||
if not job.next_milestone_action:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'No milestone advance available — finish all steps first.',
|
||||
}
|
||||
try:
|
||||
job.action_advance_next_milestone()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/advance_milestone failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info(
|
||||
"Job %s milestone advanced by uid %s", job.name, env.uid,
|
||||
)
|
||||
job.invalidate_recordset([
|
||||
'workflow_state_id',
|
||||
'next_milestone_action',
|
||||
'next_milestone_label',
|
||||
])
|
||||
return {
|
||||
'ok': True,
|
||||
'workflow_state': {
|
||||
'id': job.workflow_state_id.id,
|
||||
'name': job.workflow_state_id.name,
|
||||
'color': job.workflow_state_id.color or 'grey',
|
||||
} if job.workflow_state_id else None,
|
||||
'next_milestone_action': job.next_milestone_action or '',
|
||||
'next_milestone_label': job.next_milestone_label or '',
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Phase 6 tablet PIN gate — default knobs.
|
||||
All overridable via Settings → Technical → Parameters → System Parameters.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="ir_config_param_tablet_idle_lock_minutes" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_idle_lock_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_pin_fail_threshold" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_pin_fail_threshold</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_pin_fail_lockout_minutes" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_pin_fail_lockout_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_warn_seconds_before_lock" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_warn_seconds_before_lock</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -8,3 +8,4 @@ from . import fp_bake_window
|
||||
from . import fp_first_piece_gate
|
||||
from . import fp_operator_queue
|
||||
from . import fp_tank
|
||||
from . import res_users
|
||||
|
||||
@@ -73,6 +73,26 @@ class FpShopfloorStation(models.Model):
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# Phase 6 tablet PIN gate — per-station roster + idle override.
|
||||
x_fc_authorised_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
relation='fp_shopfloor_station_authorised_user_rel',
|
||||
column1='station_id',
|
||||
column2='user_id',
|
||||
string='Authorised Operators',
|
||||
help='If set, the tablet lock screen only shows tiles for these '
|
||||
'users. Empty = all operator-group users are shown. Use to '
|
||||
'restrict a tablet at a specialised station (e.g. EN Plating) '
|
||||
'to techs trained on that station.',
|
||||
)
|
||||
x_fc_idle_lock_minutes = fields.Integer(
|
||||
string='Idle Lock (minutes)',
|
||||
help='Per-station override for the auto-lock idle threshold. '
|
||||
'Leave blank to use the global default '
|
||||
'(ir.config_parameter fp.shopfloor.tablet_idle_lock_minutes, '
|
||||
'default 5).',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_shopfloor_station_code_uniq',
|
||||
|
||||
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
|
||||
|
||||
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
|
||||
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
|
||||
hash; never plaintext.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
|
||||
# against brute-force even if the DB leaks.
|
||||
_PBKDF2_ITERATIONS = 200_000
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
x_fc_tablet_pin_hash = fields.Char(
|
||||
string='Tablet PIN (hashed)',
|
||||
groups='fusion_plating.group_fusion_plating_manager',
|
||||
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
|
||||
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
|
||||
'non-managers; never logged.',
|
||||
)
|
||||
x_fc_tablet_pin_set_date = fields.Datetime(
|
||||
string='Tablet PIN Set Date',
|
||||
help='When the current PIN was last set or changed.',
|
||||
)
|
||||
x_fc_tablet_pin_failed_count = fields.Integer(
|
||||
string='Failed PIN Attempts',
|
||||
default=0,
|
||||
help='Sequential failed unlock attempts since the last success. '
|
||||
'Resets to 0 on a correct PIN.',
|
||||
)
|
||||
x_fc_tablet_locked_until = fields.Datetime(
|
||||
string='Tablet Lockout Until',
|
||||
help='Wall-clock time at which the per-user lockout expires. '
|
||||
'Null when not locked. Set after the configured fail '
|
||||
'threshold (default 5) is reached.',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hash_tablet_pin(pin, salt=None):
|
||||
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||
)
|
||||
return f"{salt.hex()}${digest.hex()}"
|
||||
|
||||
@staticmethod
|
||||
def _verify_tablet_pin_hash(pin, stored):
|
||||
"""Constant-time verify of `pin` against a stored hash string."""
|
||||
if not stored or '$' not in stored:
|
||||
return False
|
||||
salt_hex, expected_hex = stored.split('$', 1)
|
||||
try:
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||
)
|
||||
return secrets.compare_digest(digest.hex(), expected_hex)
|
||||
|
||||
def set_tablet_pin(self, pin):
|
||||
"""Set or change this user's tablet PIN. Requires sudo OR self.
|
||||
|
||||
Caller is responsible for verifying the OLD pin separately if a
|
||||
hash already exists — this method just writes the new one.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not pin or not pin.isdigit() or len(pin) != 4:
|
||||
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
|
||||
self.sudo().write({
|
||||
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
|
||||
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
return True
|
||||
|
||||
def verify_tablet_pin(self, pin):
|
||||
"""Return True if `pin` matches this user's stored hash."""
|
||||
self.ensure_one()
|
||||
if not pin:
|
||||
return False
|
||||
# sudo: even non-manager callers may need to verify their OWN PIN.
|
||||
# The hash field has manager-only read; sudo bypasses that.
|
||||
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
|
||||
|
||||
def clear_tablet_pin(self):
|
||||
"""Manager-side reset. Clears hash so target must set a new PIN.
|
||||
Posts to chatter for audit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
manager_name = self.env.user.name
|
||||
self.sudo().write({
|
||||
'x_fc_tablet_pin_hash': False,
|
||||
'x_fc_tablet_pin_set_date': False,
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
self.message_post(
|
||||
body=_('Tablet PIN reset by %s. User must set a new PIN '
|
||||
'on next unlock attempt.') % manager_name,
|
||||
)
|
||||
return True
|
||||
|
||||
def action_open_tablet_pin_setup(self):
|
||||
"""Trigger the FpPinSetup OWL modal from the Preferences form.
|
||||
The Phase 6.2 OWL component intercepts this action tag.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_tablet_pin_setup',
|
||||
'name': 'Set Tablet PIN',
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -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_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_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
|
||||
// off-roster names.
|
||||
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
|
||||
|
||||
onMounted(async () => {
|
||||
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
|
||||
// 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(() => {
|
||||
@@ -283,6 +295,85 @@ export class ManagerDashboard extends Component {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user