Compare commits
41 Commits
3f3ddcbab4
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c898aadf | ||
|
|
6c4ff7751f | ||
|
|
956678dd27 | ||
|
|
e52477e2ba | ||
|
|
83271ee69e | ||
|
|
082c585e24 | ||
|
|
afc01ec1d9 | ||
|
|
11f7791c5e | ||
|
|
81277edb25 | ||
|
|
2588a2b651 | ||
|
|
83a999afad | ||
|
|
067d1f01c8 | ||
|
|
6d1efc6c43 | ||
|
|
298f5942eb | ||
|
|
ae03e32b5d | ||
|
|
d29857078a | ||
|
|
a660f1f05d | ||
|
|
f340c87b6a | ||
|
|
1c6a460ca1 | ||
|
|
095d9f487c | ||
|
|
28dd7fdd76 | ||
|
|
f94be9dfa9 | ||
|
|
70fe10c214 | ||
|
|
b85642816f | ||
|
|
b09538b4e2 | ||
|
|
e07002d550 | ||
|
|
3b5b5cbf7c | ||
|
|
adc27c637a | ||
|
|
838b41cb89 | ||
|
|
cb79186325 | ||
|
|
edd52f16a7 | ||
|
|
22b06f47d9 | ||
|
|
71bd0da5e1 | ||
|
|
44a980c468 | ||
|
|
66f7f6c644 | ||
|
|
96ecf7a9e1 | ||
|
|
fbaf318832 | ||
|
|
a623c6684d | ||
|
|
6658544f85 | ||
|
|
d3dd6376a6 | ||
|
|
7c7ef06057 |
54
CLAUDE.md
54
CLAUDE.md
@@ -14,6 +14,60 @@
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
7. **Search views**: NO `group expand="0"` syntax.
|
||||
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
```css
|
||||
background-color: white;
|
||||
border: 1px solid #d8dadd;
|
||||
```
|
||||
For custom OWL dashboards / client actions use the same approach:
|
||||
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
|
||||
```scss
|
||||
$fp-card: var(--fp-card-bg, #ffffff);
|
||||
$fp-border: var(--fp-border-color, #d8dadd);
|
||||
```
|
||||
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
|
||||
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
|
||||
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
|
||||
|
||||
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
|
||||
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
|
||||
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
|
||||
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
|
||||
|
||||
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
|
||||
|
||||
```scss
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_my-page-hex: #f3f4f6;
|
||||
$_my-card-hex: #ffffff;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_my-page-hex: #1a1d21 !global;
|
||||
$_my-card-hex: #22262d !global;
|
||||
}
|
||||
|
||||
$my-page: var(--my-page-bg, $_my-page-hex);
|
||||
$my-card: var(--my-card-bg, $_my-card-hex);
|
||||
```
|
||||
|
||||
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
|
||||
|
||||
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
|
||||
```python
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
|
||||
```
|
||||
|
||||
## Asset Bundle Cache Busting
|
||||
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
|
||||
1. Bump the module `version` in `__manifest__.py`
|
||||
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
|
||||
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
|
||||
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
|
||||
|
||||
## Naming
|
||||
- New fields: `x_fc_*` prefix
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
# ----- Facility footprint for this legal entity ----------------------
|
||||
x_fc_facility_ids = fields.One2many(
|
||||
'fusion.plating.facility',
|
||||
'company_id',
|
||||
string='Plating Facilities',
|
||||
)
|
||||
x_fc_facility_count = fields.Integer(
|
||||
string='# Facilities',
|
||||
compute='_compute_x_fc_facility_count',
|
||||
)
|
||||
x_fc_default_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Default Facility',
|
||||
help='Facility used when the context does not specify one (single-site shops).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_facility_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
|
||||
@@ -1,36 +0,0 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Extend delivery to auto-update portal job when delivered.
|
||||
|
||||
GAP 5: Delivery marked "delivered" → portal job → "shipped"
|
||||
+ set actual_ship_date on the job.
|
||||
"""
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Override to cascade delivery completion to the portal job."""
|
||||
res = super().action_mark_delivered()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
# Find the portal job by name/reference
|
||||
job = PortalJob.search(
|
||||
[('name', '=', delivery.job_ref)], limit=1,
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
job.write({
|
||||
'state': 'shipped',
|
||||
'actual_ship_date': fields.Date.today(),
|
||||
'tracking_ref': delivery.name,
|
||||
})
|
||||
job.message_post(body='Parts shipped — delivery %s marked delivered.' % delivery.name)
|
||||
return res
|
||||
@@ -1,246 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MrpProduction(models.Model):
|
||||
"""Extend manufacturing order with Fusion Plating references and
|
||||
workflow automations that bridge MO lifecycle → portal job → delivery.
|
||||
"""
|
||||
_inherit = 'mrp.production'
|
||||
|
||||
x_fc_customer_spec_id = fields.Many2one(
|
||||
'fusion.plating.customer.spec',
|
||||
string='Customer Spec',
|
||||
help='The customer specification governing this manufacturing order.',
|
||||
)
|
||||
x_fc_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
help='The Fusion Plating facility where this order is produced.',
|
||||
)
|
||||
x_fc_portal_job_id = fields.Many2one(
|
||||
'fusion.plating.portal.job',
|
||||
string='Portal Job',
|
||||
help='The portal job linked to this manufacturing order.',
|
||||
)
|
||||
x_fc_recipe_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe',
|
||||
domain=[('node_type', '=', 'recipe')],
|
||||
help='Process recipe template for this manufacturing order.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_override_ids = fields.One2many(
|
||||
'fusion.plating.job.node.override',
|
||||
'production_id',
|
||||
string='Recipe Overrides',
|
||||
)
|
||||
x_fc_override_count = fields.Integer(
|
||||
string='Overrides',
|
||||
compute='_compute_override_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_override_ids')
|
||||
def _compute_override_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_override_count = len(rec.x_fc_override_ids)
|
||||
|
||||
def action_configure_recipe_steps(self):
|
||||
"""Open the wizard to configure opt-in/out steps for this job."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_recipe_id:
|
||||
raise UserError(_('Please select a recipe first.'))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Configure Steps — {self.x_fc_recipe_id.name}',
|
||||
'res_model': 'fp.recipe.config.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_production_id': self.id,
|
||||
'default_recipe_id': self.x_fc_recipe_id.id,
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recipe → Work Order generation
|
||||
# ------------------------------------------------------------------
|
||||
def _generate_workorders_from_recipe(self):
|
||||
"""Generate mrp.workorder records from the assigned recipe.
|
||||
|
||||
Walks the recipe tree, creates one WO per 'operation' node,
|
||||
and formats child 'step' nodes as WO instructions.
|
||||
Respects opt-in/out overrides from x_fc_override_ids.
|
||||
"""
|
||||
WorkOrder = self.env['mrp.workorder']
|
||||
for production in self:
|
||||
if not production.x_fc_recipe_id:
|
||||
continue # No recipe assigned
|
||||
if production.workorder_ids:
|
||||
continue # WOs already exist — don't duplicate
|
||||
|
||||
# Build lookup of overrides keyed by node ID
|
||||
override_map = {} # {node_id: included_bool}
|
||||
for override in production.x_fc_override_ids:
|
||||
override_map[override.node_id.id] = override.included
|
||||
|
||||
# Walk tree and collect operation WO values
|
||||
wo_vals_list = []
|
||||
seq_counter = [10] # mutable for closure, increments by 10
|
||||
|
||||
def _is_node_included(node):
|
||||
"""Determine if a node should be included based on opt-in/out
|
||||
logic and per-job overrides.
|
||||
|
||||
- disabled: always included (not configurable)
|
||||
- opt_in: excluded by default, included only with override
|
||||
- opt_out: included by default, excluded only with override
|
||||
"""
|
||||
nid = node.id
|
||||
opt = node.opt_in_out or 'disabled'
|
||||
if opt == 'disabled':
|
||||
return True
|
||||
if nid in override_map:
|
||||
return override_map[nid]
|
||||
# No override → use default
|
||||
if opt == 'opt_in':
|
||||
return False # Default excluded
|
||||
# opt_out → default included
|
||||
return True
|
||||
|
||||
def walk_node(node):
|
||||
if not _is_node_included(node):
|
||||
return
|
||||
|
||||
if node.node_type == 'operation':
|
||||
# Map FP work centre → MRP work centre
|
||||
mrp_wc = False
|
||||
if node.work_center_id and node.work_center_id.x_fc_mrp_workcenter_id:
|
||||
mrp_wc = node.work_center_id.x_fc_mrp_workcenter_id.id
|
||||
if not mrp_wc:
|
||||
_logger.warning(
|
||||
'MO %s: operation "%s" has no mapped MRP work centre — '
|
||||
'skipping WO creation.',
|
||||
production.name, node.name,
|
||||
)
|
||||
# Still recurse into children for nested sub-operations
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
walk_node(child)
|
||||
return
|
||||
|
||||
# Collect step instructions from child 'step' nodes
|
||||
steps = []
|
||||
step_num = 1
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
if child.node_type == 'step' and _is_node_included(child):
|
||||
line = '%d. %s' % (step_num, child.name)
|
||||
if child.estimated_duration:
|
||||
line += ' (%.0f min)' % child.estimated_duration
|
||||
steps.append(line)
|
||||
step_num += 1
|
||||
|
||||
wo_vals_list.append({
|
||||
'production_id': production.id,
|
||||
'name': node.name,
|
||||
'workcenter_id': mrp_wc,
|
||||
'duration_expected': node.estimated_duration or 0,
|
||||
'sequence': seq_counter[0],
|
||||
'description': '\n'.join(steps) if steps else '',
|
||||
})
|
||||
seq_counter[0] += 10
|
||||
|
||||
elif node.node_type in ('recipe', 'sub_process'):
|
||||
# Container nodes — recurse into children
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
walk_node(child)
|
||||
# 'step' nodes at top level are handled by their parent operation
|
||||
|
||||
# Start walking from recipe root
|
||||
walk_node(production.x_fc_recipe_id)
|
||||
|
||||
# Bulk create work orders
|
||||
if wo_vals_list:
|
||||
WorkOrder.create(wo_vals_list)
|
||||
production.message_post(
|
||||
body=_('%d work orders generated from recipe "%s".') % (
|
||||
len(wo_vals_list), production.x_fc_recipe_id.name),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs
|
||||
# ------------------------------------------------------------------
|
||||
def action_confirm(self):
|
||||
"""Override to auto-create a portal job and generate work orders
|
||||
from the assigned recipe when the MO is confirmed.
|
||||
"""
|
||||
res = super().action_confirm()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for mo in self:
|
||||
if mo.x_fc_portal_job_id:
|
||||
# Already linked — just update state
|
||||
mo.x_fc_portal_job_id.write({'state': 'in_progress'})
|
||||
continue
|
||||
# Resolve customer from sale order via origin
|
||||
partner = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if so:
|
||||
partner = so.partner_id
|
||||
if not partner:
|
||||
continue # No customer — skip portal job creation
|
||||
job = PortalJob.create({
|
||||
'name': mo.name,
|
||||
'partner_id': partner.id,
|
||||
'state': 'in_progress',
|
||||
'received_date': fields.Date.today(),
|
||||
'target_ship_date': (
|
||||
mo.date_start.date() + __import__('datetime').timedelta(days=10)
|
||||
if mo.date_start else False
|
||||
),
|
||||
'quantity': int(mo.product_qty),
|
||||
'company_id': mo.company_id.id,
|
||||
})
|
||||
mo.x_fc_portal_job_id = job
|
||||
|
||||
# Generate work orders from recipe (after portal job creation)
|
||||
self._generate_workorders_from_recipe()
|
||||
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 3+4: MO done → update portal job + auto-create delivery
|
||||
# ------------------------------------------------------------------
|
||||
def button_mark_done(self):
|
||||
"""Override to cascade MO completion to portal job and delivery."""
|
||||
res = super().button_mark_done()
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
for mo in self:
|
||||
job = mo.x_fc_portal_job_id
|
||||
if not job:
|
||||
continue
|
||||
# GAP 3: MO done → portal job ready_to_ship
|
||||
job.write({'state': 'ready_to_ship'})
|
||||
job.message_post(body='Manufacturing complete — ready to ship.')
|
||||
|
||||
# GAP 4: Auto-create delivery record
|
||||
if Delivery is None:
|
||||
continue
|
||||
partner = job.partner_id
|
||||
Delivery.create({
|
||||
'partner_id': partner.id,
|
||||
'job_ref': job.name,
|
||||
'source_facility_id': mo.x_fc_facility_id.id if mo.x_fc_facility_id else False,
|
||||
'state': 'draft',
|
||||
})
|
||||
return res
|
||||
@@ -1,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- Extend mrp.production form: add Fusion Plating fields -->
|
||||
<record id="view_mrp_production_form_fp_bridge" model="ir.ui.view">
|
||||
<field name="name">mrp.production.form.fp.bridge</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Plating" name="fusion_plating">
|
||||
<group>
|
||||
<field name="x_fc_customer_spec_id"/>
|
||||
<field name="x_fc_facility_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_portal_job_id"/>
|
||||
<field name="x_fc_recipe_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_configure_recipe_steps" type="object"
|
||||
class="oe_stat_button" icon="fa-sliders"
|
||||
invisible="not x_fc_recipe_id">
|
||||
<field name="x_fc_override_count" widget="statinfo"
|
||||
string="Overrides"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,128 +0,0 @@
|
||||
# -*- 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, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCertificate(models.Model):
|
||||
"""Unified certificate registry.
|
||||
|
||||
Logs every quality document issued to customers: CoC, thickness
|
||||
reports, mill test reports, Nadcap certs, and customer-specific
|
||||
formats. Auto-created when reports are generated.
|
||||
"""
|
||||
_name = 'fp.certificate'
|
||||
_description = 'Fusion Plating — Certificate'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'issue_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
|
||||
certificate_type = fields.Selection(
|
||||
[
|
||||
('coc', 'Certificate of Conformance'),
|
||||
('thickness_report', 'Thickness Report'),
|
||||
('mill_test', 'Mill Test Report'),
|
||||
('nadcap_cert', 'Nadcap Certificate'),
|
||||
('customer_specific', 'Customer-Specific'),
|
||||
],
|
||||
string='Type', required=True, default='coc', tracking=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True, tracking=True,
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
)
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
|
||||
production_id = fields.Many2one('mrp.production', string='Manufacturing Order')
|
||||
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
|
||||
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
|
||||
process_description = fields.Char(
|
||||
string='Process', help='e.g. "ELECTROLESS NICKEL PLATING PER AMS 2404"',
|
||||
)
|
||||
spec_reference = fields.Char(string='Spec Reference')
|
||||
po_number = fields.Char(string='Customer PO #')
|
||||
entech_wo_number = fields.Char(string='Entech WO #')
|
||||
quantity_shipped = fields.Integer(string='Qty Shipped')
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||
)
|
||||
certified_by_id = fields.Many2one(
|
||||
'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).',
|
||||
)
|
||||
issue_date = fields.Date(string='Issue Date', default=fields.Date.today, tracking=True)
|
||||
attachment_id = fields.Many2one('ir.attachment', string='Certificate PDF')
|
||||
thickness_reading_ids = fields.One2many(
|
||||
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
||||
string='Status', default='draft', tracking=True, required=True,
|
||||
)
|
||||
void_reason = fields.Text(string='Void Reason')
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
# ----- Computed stats from readings -------------------------------------
|
||||
reading_count = fields.Integer(
|
||||
string='Readings', compute='_compute_reading_stats',
|
||||
)
|
||||
mean_nip_mils = fields.Float(
|
||||
string='Mean NiP (mils)', compute='_compute_reading_stats', digits=(10, 4),
|
||||
)
|
||||
|
||||
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils')
|
||||
def _compute_reading_stats(self):
|
||||
for rec in self:
|
||||
readings = rec.thickness_reading_ids
|
||||
rec.reading_count = len(readings)
|
||||
if readings:
|
||||
nip_values = readings.mapped('nip_mils')
|
||||
rec.mean_nip_mils = sum(nip_values) / len(nip_values) if nip_values else 0
|
||||
else:
|
||||
rec.mean_nip_mils = 0
|
||||
|
||||
# ----- Sequence ---------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
|
||||
return super().create(vals_list)
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
rec.state = 'issued'
|
||||
rec.message_post(body=_('Certificate issued.'))
|
||||
|
||||
def action_void(self):
|
||||
for rec in self:
|
||||
if rec.state != 'issued':
|
||||
raise UserError(_('Only issued certificates can be voided.'))
|
||||
if not rec.void_reason:
|
||||
raise UserError(_('Please enter a void reason before voiding.'))
|
||||
rec.state = 'voided'
|
||||
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
|
||||
|
||||
def action_send_to_customer(self):
|
||||
"""Open email composer with the certificate PDF attached."""
|
||||
self.ensure_one()
|
||||
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
||||
ctx = {
|
||||
'default_model': 'fp.certificate',
|
||||
'default_res_ids': self.ids,
|
||||
'default_composition_mode': 'comment',
|
||||
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
|
||||
}
|
||||
if self.attachment_id:
|
||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'mail.compose.message',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id_invoice_strategy(self):
|
||||
"""Auto-fill invoice strategy from customer defaults."""
|
||||
if self.partner_id:
|
||||
default = self.env['fp.invoice.strategy.default'].search(
|
||||
[('partner_id', '=', self.partner_id.id)], limit=1,
|
||||
)
|
||||
if default:
|
||||
self.x_fc_invoice_strategy = default.default_strategy
|
||||
self.x_fc_deposit_percent = default.default_deposit_percent
|
||||
if default.payment_term_id:
|
||||
self.payment_term_id = default.payment_term_id
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to check account hold and trigger invoice strategy."""
|
||||
for order in self:
|
||||
# --- Account hold check ---
|
||||
if order.partner_id.x_fc_account_hold:
|
||||
is_manager = self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'
|
||||
)
|
||||
if not is_manager:
|
||||
raise UserError(_(
|
||||
'Cannot confirm — customer "%s" is on account hold.\n'
|
||||
'Reason: %s\n\n'
|
||||
'Contact a manager to override.'
|
||||
) % (order.partner_id.name,
|
||||
order.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
||||
else:
|
||||
# Manager gets a warning in chatter but can proceed
|
||||
order.message_post(
|
||||
body=_(
|
||||
'Warning: Customer "%s" is on account hold (reason: %s). '
|
||||
'Order confirmed by manager override.'
|
||||
) % (order.partner_id.name,
|
||||
order.partner_id.x_fc_account_hold_reason or 'N/A'),
|
||||
)
|
||||
|
||||
res = super().action_confirm()
|
||||
|
||||
# --- Invoice strategy automation ---
|
||||
for order in self:
|
||||
strategy = order.x_fc_invoice_strategy
|
||||
if not strategy:
|
||||
continue
|
||||
|
||||
if strategy == 'deposit' and order.x_fc_deposit_percent:
|
||||
order._create_deposit_invoice()
|
||||
elif strategy == 'cod_prepay':
|
||||
order._create_full_invoice()
|
||||
|
||||
return res
|
||||
|
||||
def _create_deposit_invoice(self):
|
||||
"""Create a deposit (down payment) invoice for the deposit percentage."""
|
||||
self.ensure_one()
|
||||
percent = self.x_fc_deposit_percent
|
||||
if not percent or percent <= 0:
|
||||
return
|
||||
|
||||
try:
|
||||
# Use Odoo's standard down payment mechanism
|
||||
wizard = self.env['sale.advance.payment.inv'].create({
|
||||
'advance_payment_method': 'percentage',
|
||||
'amount': percent,
|
||||
})
|
||||
wizard.with_context(active_ids=self.ids, active_model='sale.order').create_invoices()
|
||||
self.message_post(
|
||||
body=_('Deposit invoice (%.0f%%) created automatically — strategy: Deposit.') % percent,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning('Failed to create deposit invoice for SO %s: %s', self.name, e)
|
||||
self.message_post(
|
||||
body=_('Failed to auto-create deposit invoice: %s. Create manually.') % str(e),
|
||||
)
|
||||
|
||||
def _create_full_invoice(self):
|
||||
"""Create a full invoice immediately (COD/Prepay strategy)."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
invoices = self._create_invoices()
|
||||
if invoices:
|
||||
self.message_post(
|
||||
body=_('Full invoice created automatically — strategy: COD / Prepay.'),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning('Failed to create COD invoice for SO %s: %s', self.name, e)
|
||||
self.message_post(
|
||||
body=_('Failed to auto-create invoice: %s. Create manually.') % str(e),
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="fp_notif_so_confirmed" model="fp.notification.template">
|
||||
<field name="name">Order Confirmation</field>
|
||||
<field name="trigger_event">so_confirmed</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_so_confirmed"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_parts_received" model="fp.notification.template">
|
||||
<field name="name">Parts Received</field>
|
||||
<field name="trigger_event">parts_received</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_parts_received"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_invoice_posted" model="fp.notification.template">
|
||||
<field name="name">Invoice Posted</field>
|
||||
<field name="trigger_event">invoice_posted</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_invoice_posted"/>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="attach_invoice" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="fp_mail_template_so_confirmed" model="mail.template">
|
||||
<field name="name">FP: Order Confirmation</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="subject">Order Confirmation — {{ object.name }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<p>Dear {{ object.partner_id.name }},</p>
|
||||
<p>Your order <strong>{{ object.name }}</strong> has been confirmed.</p>
|
||||
<p>We will notify you when your parts have been received at our facility.</p>
|
||||
<p>Thank you for your business.</p>
|
||||
<p>— EN Technologies Inc.</p>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_mail_template_parts_received" model="mail.template">
|
||||
<field name="name">FP: Parts Received</field>
|
||||
<field name="model_id" eval="env['ir.model']._get_id('fp.receiving')"/>
|
||||
<field name="subject">Parts Received — {{ object.name }}</field>
|
||||
<field name="email_from">{{ (object.sale_order_id.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<p>Dear {{ object.partner_id.name }},</p>
|
||||
<p>We have received your parts for order <strong>{{ object.sale_order_id.name }}</strong>.</p>
|
||||
<p>Quantity received: {{ object.received_qty }}</p>
|
||||
<p>Your parts are now in our production queue. We will keep you updated on progress.</p>
|
||||
<p>— EN Technologies Inc.</p>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_mail_template_invoice_posted" model="mail.template">
|
||||
<field name="name">FP: Invoice Notification</field>
|
||||
<field name="model_id" ref="account.model_account_move"/>
|
||||
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<p>Dear {{ object.partner_id.name }},</p>
|
||||
<p>Please find your invoice <strong>{{ object.name }}</strong> for amount <strong>{{ object.amount_total }}</strong>.</p>
|
||||
<p>Thank you for your business.</p>
|
||||
<p>— EN Technologies Inc.</p>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,58 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
for move in self:
|
||||
if move.move_type == 'out_invoice' and move.partner_id:
|
||||
# Find linked SO
|
||||
so = False
|
||||
if move.invoice_origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', move.invoice_origin)], limit=1,
|
||||
)
|
||||
self._send_fp_notification(
|
||||
'invoice_posted', move, move.partner_id, sale_order=so,
|
||||
)
|
||||
return res
|
||||
|
||||
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
|
||||
"""Send a notification email and log it."""
|
||||
template = self.env['fp.notification.template'].search(
|
||||
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
|
||||
)
|
||||
if not template or not template.mail_template_id:
|
||||
return
|
||||
try:
|
||||
template.mail_template_id.send_mail(record.id, force_send=False)
|
||||
self.env['fp.notification.log'].create({
|
||||
'template_id': template.id,
|
||||
'trigger_event': trigger_event,
|
||||
'sale_order_id': sale_order.id if sale_order else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'recipient_email': partner.email if partner else '',
|
||||
'status': 'sent',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
|
||||
self.env['fp.notification.log'].create({
|
||||
'template_id': template.id,
|
||||
'trigger_event': trigger_event,
|
||||
'sale_order_id': sale_order.id if sale_order else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'recipient_email': partner.email if partner else '',
|
||||
'status': 'failed',
|
||||
'error_message': str(e),
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
TRIGGER_EVENTS = [
|
||||
('so_confirmed', 'Order Confirmed'),
|
||||
('parts_received', 'Parts Received'),
|
||||
('mo_complete', 'Manufacturing Complete'),
|
||||
('shipment', 'Shipment (Carrier)'),
|
||||
('delivery', 'Delivery (Local)'),
|
||||
('invoice_posted', 'Invoice Posted'),
|
||||
('deposit_created', 'Deposit Required'),
|
||||
]
|
||||
|
||||
|
||||
class FpNotificationTemplate(models.Model):
|
||||
"""Configurable notification wrapper.
|
||||
|
||||
Each record maps a trigger event to a mail.template and controls
|
||||
whether the notification fires and what attachments are included.
|
||||
"""
|
||||
_name = 'fp.notification.template'
|
||||
_description = 'Fusion Plating — Notification Template'
|
||||
_order = 'trigger_event'
|
||||
|
||||
name = fields.Char(string='Template Name', required=True)
|
||||
trigger_event = fields.Selection(
|
||||
TRIGGER_EVENTS, string='Trigger Event', required=True,
|
||||
)
|
||||
mail_template_id = fields.Many2one(
|
||||
'mail.template', string='Email Template',
|
||||
help='The Odoo mail template used to render and send the email.',
|
||||
)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
attach_coc = fields.Boolean(string='Attach CoC')
|
||||
attach_thickness_report = fields.Boolean(string='Attach Thickness Report')
|
||||
attach_invoice = fields.Boolean(string='Attach Invoice')
|
||||
attach_packing_list = fields.Boolean(string='Attach Packing List')
|
||||
attach_pod = fields.Boolean(string='Attach Proof of Delivery')
|
||||
cc_internal_ids = fields.Many2many(
|
||||
'res.users', 'fp_notification_template_cc_rel',
|
||||
'template_id', 'user_id', string='CC (Internal)',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_notification_trigger_uniq', 'unique(trigger_event)',
|
||||
'Only one notification template per trigger event.'),
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
def action_accept(self):
|
||||
res = super().action_accept()
|
||||
for rec in self:
|
||||
self._send_fp_notification(
|
||||
'parts_received', rec, rec.partner_id,
|
||||
sale_order=rec.sale_order_id,
|
||||
)
|
||||
return res
|
||||
|
||||
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
|
||||
"""Send a notification email and log it."""
|
||||
template = self.env['fp.notification.template'].search(
|
||||
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
|
||||
)
|
||||
if not template or not template.mail_template_id:
|
||||
return
|
||||
try:
|
||||
template.mail_template_id.send_mail(record.id, force_send=False)
|
||||
self.env['fp.notification.log'].create({
|
||||
'template_id': template.id,
|
||||
'trigger_event': trigger_event,
|
||||
'sale_order_id': sale_order.id if sale_order else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'recipient_email': partner.email if partner else '',
|
||||
'status': 'sent',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
|
||||
self.env['fp.notification.log'].create({
|
||||
'template_id': template.id,
|
||||
'trigger_event': trigger_event,
|
||||
'sale_order_id': sale_order.id if sale_order else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'recipient_email': partner.email if partner else '',
|
||||
'status': 'failed',
|
||||
'error_message': str(e),
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def action_confirm(self):
|
||||
res = super().action_confirm()
|
||||
for order in self:
|
||||
self._send_fp_notification(
|
||||
'so_confirmed', order, order.partner_id, sale_order=order,
|
||||
)
|
||||
return res
|
||||
|
||||
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
|
||||
"""Send a notification email and log it."""
|
||||
template = self.env['fp.notification.template'].search(
|
||||
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
|
||||
)
|
||||
if not template or not template.mail_template_id:
|
||||
return
|
||||
try:
|
||||
template.mail_template_id.send_mail(record.id, force_send=False)
|
||||
self.env['fp.notification.log'].create({
|
||||
'template_id': template.id,
|
||||
'trigger_event': trigger_event,
|
||||
'sale_order_id': sale_order.id if sale_order else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'recipient_email': partner.email if partner else '',
|
||||
'status': 'sent',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
|
||||
self.env['fp.notification.log'].create({
|
||||
'template_id': template.id,
|
||||
'trigger_event': trigger_event,
|
||||
'sale_order_id': sale_order.id if sale_order else False,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'recipient_email': partner.email if partner else '',
|
||||
'status': 'failed',
|
||||
'error_message': str(e),
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -1,205 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
Paper format + report actions for all Fusion Plating reports.
|
||||
-->
|
||||
<odoo>
|
||||
<!-- ============================================================= -->
|
||||
<!-- Landscape Paper Format -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="paperformat_fp_a4_landscape" model="report.paperformat">
|
||||
<field name="name">A4 Landscape (Fusion Plating)</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="format">A4</field>
|
||||
<field name="orientation">Landscape</field>
|
||||
<field name="margin_top">20</field>
|
||||
<field name="margin_bottom">20</field>
|
||||
<field name="margin_left">7</field>
|
||||
<field name="margin_right">7</field>
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">20</field>
|
||||
<field name="dpi">90</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 1. Certificate of Conformance (Portal Job) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_coc" model="ir.actions.report">
|
||||
<field name="name">Certificate of Conformance</field>
|
||||
<field name="model">fusion.plating.portal.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_coc</field>
|
||||
<field name="report_file">fusion_plating_reports.report_coc</field>
|
||||
<field name="print_report_name">'CoC - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_portal.model_fusion_plating_portal_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 2. Non-Conformance Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_ncr" model="ir.actions.report">
|
||||
<field name="name">Non-Conformance Report</field>
|
||||
<field name="model">fusion.plating.ncr</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_ncr</field>
|
||||
<field name="report_file">fusion_plating_reports.report_ncr</field>
|
||||
<field name="print_report_name">'NCR - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_ncr"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 3. Corrective / Preventive Action -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_capa" model="ir.actions.report">
|
||||
<field name="name">CAPA Report</field>
|
||||
<field name="model">fusion.plating.capa</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_capa</field>
|
||||
<field name="report_file">fusion_plating_reports.report_capa</field>
|
||||
<field name="print_report_name">'CAPA - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_capa"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 4. Bath Chemistry Log -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_bath_log" model="ir.actions.report">
|
||||
<field name="name">Bath Chemistry Log</field>
|
||||
<field name="model">fusion.plating.bath.log</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_bath_chemistry_log</field>
|
||||
<field name="report_file">fusion_plating_reports.report_bath_chemistry_log</field>
|
||||
<field name="print_report_name">'Bath Log - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fusion_plating_bath_log"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 5. Calibration Certificate -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_calibration" model="ir.actions.report">
|
||||
<field name="name">Calibration Certificate</field>
|
||||
<field name="model">fusion.plating.calibration.equipment</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_calibration_cert</field>
|
||||
<field name="report_file">fusion_plating_reports.report_calibration_cert</field>
|
||||
<field name="print_report_name">'Calibration - %s' % object.code</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_calibration_equipment"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 6. First Article Inspection Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_fair" model="ir.actions.report">
|
||||
<field name="name">FAIR Report</field>
|
||||
<field name="model">fusion.plating.fair</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fair</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fair</field>
|
||||
<field name="print_report_name">'FAIR - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_fair"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 7. Audit Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_audit" model="ir.actions.report">
|
||||
<field name="name">Audit Report</field>
|
||||
<field name="model">fusion.plating.audit</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_audit</field>
|
||||
<field name="report_file">fusion_plating_reports.report_audit</field>
|
||||
<field name="print_report_name">'Audit - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_quality.model_fusion_plating_audit"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 8. Incident Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_incident" model="ir.actions.report">
|
||||
<field name="name">Incident Report</field>
|
||||
<field name="model">fusion.plating.incident</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_incident</field>
|
||||
<field name="report_file">fusion_plating_reports.report_incident</field>
|
||||
<field name="print_report_name">'Incident - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_safety.model_fusion_plating_incident"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 9. Spill Register -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_spill" model="ir.actions.report">
|
||||
<field name="name">Spill Report</field>
|
||||
<field name="model">fusion.plating.spill.register</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_spill</field>
|
||||
<field name="report_file">fusion_plating_reports.report_spill</field>
|
||||
<field name="print_report_name">'Spill - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_spill_register"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 10. Waste Manifest -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_waste_manifest" model="ir.actions.report">
|
||||
<field name="name">Waste Manifest</field>
|
||||
<field name="model">fusion.plating.waste.manifest</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_waste_manifest</field>
|
||||
<field name="report_file">fusion_plating_reports.report_waste_manifest</field>
|
||||
<field name="print_report_name">'Waste Manifest - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_waste_manifest"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 11. Discharge Sample -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_discharge_sample" model="ir.actions.report">
|
||||
<field name="name">Discharge Sample Report</field>
|
||||
<field name="model">fusion.plating.discharge.sample</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_discharge_sample</field>
|
||||
<field name="report_file">fusion_plating_reports.report_discharge_sample</field>
|
||||
<field name="print_report_name">'Discharge - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_compliance.model_fusion_plating_discharge_sample"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 12. Work Order Margin Report -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="action_report_wo_margin" model="ir.actions.report">
|
||||
<field name="name">Work Order Margin Report</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_wo_margin</field>
|
||||
<field name="report_file">fusion_plating_reports.report_wo_margin</field>
|
||||
<field name="print_report_name">'Margin Report - %s' % object.name</field>
|
||||
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
Shared landscape CSS for all Fusion Plating reports.
|
||||
-->
|
||||
<odoo>
|
||||
<template id="fp_landscape_styles">
|
||||
<style>
|
||||
.fp-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.fp-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
.fp-landscape table.bordered, .fp-landscape table.bordered th, .fp-landscape table.bordered td { border: 1px solid #000; }
|
||||
.fp-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fp-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||
.fp-landscape .text-center { text-align: center; }
|
||||
.fp-landscape .text-end { text-align: right; }
|
||||
.fp-landscape .text-start { text-align: left; }
|
||||
.fp-landscape .adp-bg { background-color: #e3f2fd; }
|
||||
.fp-landscape .client-bg { background-color: #fff3e0; }
|
||||
.fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fp-landscape .note-row { font-style: italic; }
|
||||
.fp-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
|
||||
.fp-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
|
||||
.fp-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||
.fp-landscape .totals-table { border: 1px solid #000; }
|
||||
.fp-landscape .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; }
|
||||
.fp-landscape .status-ok { color: #2e7d32; font-weight: bold; }
|
||||
.fp-landscape .status-warning { color: #f57f17; font-weight: bold; }
|
||||
.fp-landscape .status-fail { color: #c62828; font-weight: bold; }
|
||||
</style>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,114 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Certificate of Conformance — Portal Job
|
||||
-->
|
||||
<odoo>
|
||||
<template id="report_coc">
|
||||
<t t-call="web.html_container">
|
||||
<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">
|
||||
<div class="page">
|
||||
<h2 style="text-align: left;">
|
||||
Certificate of Conformance
|
||||
<span t-field="doc.name"/>
|
||||
</h2>
|
||||
|
||||
<!-- Job Info -->
|
||||
<table class="bordered info-table">
|
||||
<thead><tr>
|
||||
<th>JOB REF</th>
|
||||
<th>CUSTOMER</th>
|
||||
<th>QUANTITY</th>
|
||||
<th>RECEIVED</th>
|
||||
<th>SHIP DATE</th>
|
||||
<th>TRACKING REF</th>
|
||||
<th>STATUS</th>
|
||||
</tr></thead>
|
||||
<tbody><tr>
|
||||
<td class="text-center"><span t-field="doc.name"/></td>
|
||||
<td><span t-field="doc.partner_id"/></td>
|
||||
<td class="text-center"><span t-field="doc.quantity"/></td>
|
||||
<td class="text-center"><span t-field="doc.received_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-field="doc.actual_ship_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td class="text-center"><span t-field="doc.tracking_ref"/></td>
|
||||
<td class="text-center"><span t-field="doc.state"/></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
|
||||
<!-- Customer Address -->
|
||||
<table class="bordered">
|
||||
<thead><tr>
|
||||
<th colspan="2">CUSTOMER DETAILS</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:30%; font-weight:bold;">Name</td>
|
||||
<td><span t-field="doc.partner_id.name"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:bold;">Address</td>
|
||||
<td>
|
||||
<span t-field="doc.partner_id" t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Processes -->
|
||||
<table class="bordered" t-if="doc.process_type_ids">
|
||||
<thead><tr>
|
||||
<th>PROCESSES APPLIED</th>
|
||||
</tr></thead>
|
||||
<tbody><tr>
|
||||
<td>
|
||||
<t t-foreach="doc.process_type_ids" t-as="pt">
|
||||
<span t-out="pt.name"/>
|
||||
<t t-if="not pt_last">, </t>
|
||||
</t>
|
||||
</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
|
||||
<!-- Certification Statement -->
|
||||
<table class="bordered">
|
||||
<tr class="section-row"><td>CERTIFICATION</td></tr>
|
||||
<tr><td style="padding: 16px 12px; font-size: 11pt;">
|
||||
This certifies that the above items were processed in accordance
|
||||
with applicable specifications and meet all requirements as stated
|
||||
in the purchase order. All work was performed in compliance with
|
||||
the quality management system.
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Notes -->
|
||||
<t t-if="doc.notes">
|
||||
<table class="bordered">
|
||||
<tr class="section-row"><td>NOTES</td></tr>
|
||||
<tr><td><t t-out="doc.notes"/></td></tr>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Signature Block -->
|
||||
<table class="bordered" style="margin-top: 30px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:50%; height: 60px; vertical-align: bottom; font-weight: bold;">
|
||||
Quality Manager Signature: ___________________________
|
||||
</td>
|
||||
<td style="width:50%; height: 60px; vertical-align: bottom; font-weight: bold;">
|
||||
Date: ___________________________
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -1,178 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor Tablet (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component using `static template` + `static props = []`
|
||||
// (note: empty array, NOT empty object).
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
|
||||
// * Registered under registry.category("actions") so the menu / record
|
||||
// action can launch it as a client action ("fp_shopfloor_tablet").
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ShopfloorTablet extends Component {
|
||||
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.scanInput = useRef("scanInput");
|
||||
|
||||
this.state = useState({
|
||||
scannedCode: "",
|
||||
station: null,
|
||||
currentTank: null,
|
||||
currentBath: null,
|
||||
currentJob: null,
|
||||
queueRows: [],
|
||||
message: "",
|
||||
messageType: "info", // info | success | warning | danger
|
||||
loading: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refreshQueue();
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers --------------------------------------------------------
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
this.state.messageType = type;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.state.currentTank = null;
|
||||
this.state.currentBath = null;
|
||||
this.state.currentJob = null;
|
||||
}
|
||||
|
||||
// ----- QR scan --------------------------------------------------------
|
||||
async onScan() {
|
||||
const code = (this.state.scannedCode || "").trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||
if (!result || !result.ok) {
|
||||
this.setMessage(
|
||||
(result && result.error) || "Unrecognised QR code",
|
||||
"danger",
|
||||
);
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.clearTargets();
|
||||
switch (result.model) {
|
||||
case "fusion.plating.tank":
|
||||
this.state.currentTank = result;
|
||||
this.setMessage(
|
||||
`Tank ${result.name} — ${result.queue_size} in queue`,
|
||||
"info",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.bath":
|
||||
this.state.currentBath = result;
|
||||
this.setMessage(`Bath ${result.name}`, "info");
|
||||
break;
|
||||
case "fusion.plating.bake.window":
|
||||
this.state.currentJob = result;
|
||||
this.setMessage(
|
||||
`Job ${result.name} — ${result.time_remaining || ""} remaining`,
|
||||
result.state === "missed_window" ? "danger" : "warning",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.shopfloor.station":
|
||||
this.state.station = result;
|
||||
this.setMessage(
|
||||
`Station paired: ${result.name}`,
|
||||
"success",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.setMessage(`Scanned ${result.model}`, "info");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
||||
} finally {
|
||||
this.state.scannedCode = "";
|
||||
this.state.loading = false;
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
}
|
||||
|
||||
onScanKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.onScan();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Bake controls --------------------------------------------------
|
||||
async onStartBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Bake started", "success");
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
|
||||
async onEndBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/end_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
|
||||
"success",
|
||||
);
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
|
||||
// ----- Queue ----------------------------------------------------------
|
||||
async refreshQueue() {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/queue", {});
|
||||
if (res && res.ok) {
|
||||
this.state.queueRows = res.rows || [];
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: queue refresh shouldn't block scanning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_shopfloor_tablet", ShopfloorTablet);
|
||||
@@ -1,280 +0,0 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor backend / tablet styles
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
|
||||
// the tablet view renders correctly in BOTH light and dark mode without any
|
||||
// duplication or media queries. Status tints use color-mix() against the
|
||||
// theme token so green/yellow/red adapt to the surface.
|
||||
//
|
||||
// background: var(--bs-body-bg)
|
||||
// surface: var(--o-view-background-color)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// primary: var(--o-action)
|
||||
// =============================================================================
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local mixin — semantic tint that respects light/dark mode
|
||||
// -----------------------------------------------------------------------------
|
||||
@mixin fp-shop-tint($color-var, $amount: 14%) {
|
||||
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
|
||||
color: var(#{$color-var});
|
||||
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tablet root container — large touch targets, generous whitespace
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
.o_fp_tablet_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_station {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_scan_row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.o_fp_tablet_message {
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.o_fp_tablet_queue_title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_item {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
.o_fp_tablet_queue_label {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_desc {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large card surface used for tank / bath info on the tablet
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 140px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bake window card — colour shifts with state
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_bake_window_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-left-width: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 160px;
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.o_fp_tablet_card_actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&[data-status="awaiting_bake"] {
|
||||
border-left-color: var(--bs-warning);
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="bake_in_progress"] {
|
||||
border-left-color: var(--bs-info, var(--o-action));
|
||||
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="baked"] {
|
||||
border-left-color: var(--bs-success);
|
||||
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="missed_window"],
|
||||
&[data-status="scrapped"] {
|
||||
border-left-color: var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large QR scan input — friendly to tablet keyboards / wedge scanners
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_scan_input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 56px;
|
||||
padding: 12px 18px;
|
||||
font-size: 1.3rem;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Big touch-friendly action button
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_big_button {
|
||||
min-height: 56px;
|
||||
min-width: 120px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--o-action);
|
||||
background-color: var(--o-action);
|
||||
color: var(--o-we-text-on-action, #fff);
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease, transform 80ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Plant Overview Dashboard
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
|
||||
// the dashboard renders correctly in BOTH light and dark mode.
|
||||
//
|
||||
// background: var(--bs-body-bg)
|
||||
// surface: var(--o-view-background-color)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// primary: var(--o-action)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_plant_overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--o-view-background-color, var(--bs-body-bg));
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// ---- Header -----------------------------------------------------------------
|
||||
|
||||
.o_fp_po_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bs-body-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
|
||||
.o_fp_po_header_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_po_title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_po_refresh_ts {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.o_fp_po_header_right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Search -----------------------------------------------------------------
|
||||
|
||||
.o_fp_po_search_box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.o_fp_po_search_icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--bs-secondary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_fp_po_search_input {
|
||||
padding: 6px 32px 6px 32px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
width: 260px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--o-action) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_search_clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_refresh_btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// ---- Columns container ------------------------------------------------------
|
||||
|
||||
.o_fp_po_columns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// ---- Single column (work centre) --------------------------------------------
|
||||
|
||||
.o_fp_po_column {
|
||||
flex: 0 0 280px;
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bs-body-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.o_fp_po_col_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-radius: 10px 10px 0 0;
|
||||
|
||||
.o_fp_po_col_name {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bs-body-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.o_fp_po_col_count {
|
||||
background: var(--bs-secondary-color);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_col_body {
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 0 0 10px 10px;
|
||||
|
||||
// Drop target highlight when dragging a card over this column
|
||||
&.o_fp_drop_target {
|
||||
background-color: color-mix(in srgb, var(--o-action) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--o-action) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Card -------------------------------------------------------------------
|
||||
|
||||
.o_fp_po_card {
|
||||
background: var(--bs-body-bg);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: $border-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: grab;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.15s, transform 0.1s, opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
border-color: darken($border-color, 10%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Dragging ghost state
|
||||
&.o_fp_dragging {
|
||||
opacity: 0.4;
|
||||
border-style: dashed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// State variants
|
||||
&.o_fp_card_progress {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
&.o_fp_card_ready {
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
}
|
||||
&.o_fp_card_done {
|
||||
border-left: 4px solid var(--bs-success);
|
||||
opacity: 0.75;
|
||||
}
|
||||
&.o_fp_card_pending {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Card top row (image + title + step badge) --------------------------------
|
||||
|
||||
.o_fp_po_card_top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o_fp_po_card_img_placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: var(--bs-tertiary-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bs-body-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_po_card_step_badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-info);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---- Priority card borders ---------------------------------------------------
|
||||
|
||||
.o_fp_po_card_hot {
|
||||
border-left: 4px solid var(--bs-danger) !important;
|
||||
background: color-mix(in srgb, var(--bs-danger) 8%, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
.o_fp_po_card_urgent {
|
||||
border-left: 4px solid var(--bs-warning) !important;
|
||||
background: color-mix(in srgb, var(--bs-warning) 8%, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
// ---- Product name and step display -------------------------------------------
|
||||
|
||||
.o_fp_po_card_product {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_step {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_customer {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2px;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_po_card_refs {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// ---- Parts progress bar -----------------------------------------------------
|
||||
|
||||
.o_fp_po_card_parts {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_bar {
|
||||
height: 6px;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_fill {
|
||||
height: 100%;
|
||||
background: var(--bs-warning);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.o_fp_po_card_last {
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// ---- Tags + date footer -----------------------------------------------------
|
||||
|
||||
.o_fp_po_card_footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_po_card_tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
|
||||
&.o_fp_tag_hot {
|
||||
background: var(--bs-danger);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_tag_priority {
|
||||
background: var(--bs-success);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_tag_attention {
|
||||
background: var(--bs-warning);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
&.o_fp_tag_default {
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_card_date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ---- Empty / no-cards -------------------------------------------------------
|
||||
|
||||
.o_fp_po_no_cards {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// ---- Responsive -------------------------------------------------------------
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_po_columns {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.o_fp_po_column {
|
||||
flex: 1 1 auto;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.o_fp_po_search_input {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.o_fp_po_header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
||||
<div class="o_fp_tablet">
|
||||
<div class="o_fp_tablet_header">
|
||||
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
|
||||
<div class="o_fp_tablet_station" t-if="state.station">
|
||||
Station: <strong t-esc="state.station.name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_scan_row">
|
||||
<input
|
||||
type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"
|
||||
/>
|
||||
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<span t-esc="state.message"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_grid">
|
||||
<div class="o_fp_tablet_card" t-if="state.currentTank">
|
||||
<div class="o_fp_tablet_card_label">Tank</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentTank.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentTank.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
|
||||
Bath: <t t-esc="state.currentTank.current_bath_name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Queue: <t t-esc="state.currentTank.queue_size"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_card" t-if="state.currentBath">
|
||||
<div class="o_fp_tablet_card_label">Bath</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentBath.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentBath.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
|
||||
Tank: <t t-esc="state.currentBath.tank_name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_bake_window_card"
|
||||
t-if="state.currentJob"
|
||||
t-att-data-status="state.currentJob.state">
|
||||
<div class="o_fp_tablet_card_label">Bake Job</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentJob.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentJob.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Remaining: <t t-esc="state.currentJob.time_remaining"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_actions">
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'awaiting_bake'"
|
||||
t-on-click="onStartBake">
|
||||
Start Bake
|
||||
</button>
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'bake_in_progress'"
|
||||
t-on-click="onEndBake">
|
||||
End Bake
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_queue">
|
||||
<div class="o_fp_tablet_queue_title">Next Up</div>
|
||||
<div t-if="!state.queueRows.length" class="text-muted">
|
||||
Queue is empty.
|
||||
</div>
|
||||
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
|
||||
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_tablet_queue_item">
|
||||
<div class="o_fp_tablet_queue_label">
|
||||
<strong t-esc="row.label"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_queue_desc text-muted">
|
||||
<t t-esc="row.description"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,949 @@
|
||||
# Fusion Accounting — Enterprise Takeover Roadmap
|
||||
|
||||
**Status:** Design (approved 2026-04-18)
|
||||
**Owner:** Nexa Systems Inc.
|
||||
**Target:** Odoo 19 Community + fusion_accounting becomes a feature-complete drop-in replacement for Odoo 19 Enterprise accounting (`account_accountant`, `account_reports`, `accountant`, `account_followup`, plus selected satellite modules) for clients deployed by Nexa Systems.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context and Goals
|
||||
|
||||
### 1.1 Current State
|
||||
|
||||
`fusion_accounting` today is a thin AI co-pilot that depends on three Enterprise modules:
|
||||
|
||||
```python
|
||||
'depends': ['account', 'account_accountant', 'account_reports', 'account_followup', 'mail']
|
||||
```
|
||||
|
||||
It adds Claude/GPT-driven tool calling, a chat panel, a dashboard, an approval workflow, and rule-based automation on top of Odoo's accounting features. It does not own any core accounting capability — it orchestrates Enterprise's APIs.
|
||||
|
||||
### 1.2 Business Driver
|
||||
|
||||
Nexa Systems deploys Odoo to clients. The Enterprise subscription cost is a friction point. The goal is to deliver Enterprise-equivalent accounting capability on Odoo 19 Community via fusion_accounting, so clients can run on Community without losing core accounting features. fusion_accounting is **not** distributed publicly (no Odoo App Store listing); it ships only as part of a Nexa client engagement.
|
||||
|
||||
### 1.3 Scope of "Takeover"
|
||||
|
||||
The Enterprise modules being targeted, with verified file counts:
|
||||
|
||||
| Enterprise Module | Files | Role | Targeted Phase |
|
||||
|---|---|---|---|
|
||||
| `account_accountant` | 232 | bank-rec widget, journal dashboard, fiscal year, auto-reconcile, deferred revenue/expense, signing | Phases 1, 3 |
|
||||
| `account_reports` | 618 | financial reports engine + 18 standard reports | Phase 2 |
|
||||
| `accountant` | 26 | menu root + glue | Phase 0 |
|
||||
| `account_followup` | 58 | customer payment reminders | Phase 5 |
|
||||
| `account_asset` | n/a | asset register, depreciation | Phase 6 |
|
||||
| `account_budget` | n/a | budgets vs actuals | Phase 6 |
|
||||
| `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, `account_edi_*` | n/a | various | Phase 7+ (per client need) |
|
||||
|
||||
### 1.4 Existing Reference Material
|
||||
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/` — current AI module (will be reorganized in Phase 0)
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/` — abandoned earlier attempt; contains 461 files of code that a Feb 2026 audit (in that folder's `AUDIT_REPORT.md`) determined to be near-verbatim copies of Odoo Enterprise. **The WIP code is not continued.** Its `__manifest__.py` is harvested as a feature checklist; its file structure as a target-architecture sanity check
|
||||
- `/Users/gurpreet/Github/RePackaged-Odoo/accounting/` — pinned snapshot of Odoo 19 Enterprise accounting source; used as reference-only for clean-room rewrites and as the diff baseline for V19→V20 upgrades
|
||||
|
||||
### 1.5 Non-Goals
|
||||
|
||||
- Not building a public commercial product (no App Store distribution, no commercial licensing pricing model)
|
||||
- Not replicating every Enterprise feature (Phase 7+ items are deferred until a real client needs them)
|
||||
- Not maintaining backward compatibility with Odoo versions before 19
|
||||
- Not rewriting Community `account` — fusion_accounting builds on top of, never replaces, Community accounting
|
||||
|
||||
---
|
||||
|
||||
## 2. Sub-Module Topology
|
||||
|
||||
fusion_accounting is split into independently installable sub-modules. Each has a single, well-bounded responsibility and a clear Enterprise counterpart it replaces.
|
||||
|
||||
### 2.1 The Sub-Modules
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
community["account<br/>Odoo Community base"]
|
||||
|
||||
core["fusion_accounting_core<br/>shared fields, lock dates, fiscal year base,<br/>company config, security groups, analytic_mixin"]
|
||||
bankrec["fusion_accounting_bank_rec<br/>reconcile widget + auto-reconcile engine"]
|
||||
reports["fusion_accounting_reports<br/>financial reports engine + standard reports"]
|
||||
dashboard["fusion_accounting_dashboard<br/>journal kanban, digest"]
|
||||
followup["fusion_accounting_followup<br/>payment reminders"]
|
||||
assets["fusion_accounting_assets<br/>asset register, depreciation"]
|
||||
budget["fusion_accounting_budget<br/>budgets vs actuals"]
|
||||
ai["fusion_accounting_ai<br/>Claude/GPT copilot + chat + dashboard tiles<br/>(current fusion_accounting code lives here)"]
|
||||
migration["fusion_accounting_migration<br/>transitional Enterprise to fusion data wizard"]
|
||||
|
||||
meta["fusion_accounting<br/>meta-module: depends on all sub-modules"]
|
||||
|
||||
core --> community
|
||||
bankrec --> core
|
||||
reports --> core
|
||||
dashboard --> core
|
||||
followup --> reports
|
||||
assets --> core
|
||||
budget --> core
|
||||
ai --> core
|
||||
migration --> core
|
||||
|
||||
ai -.optional adapter calls.-> bankrec
|
||||
ai -.optional adapter calls.-> reports
|
||||
ai -.optional adapter calls.-> followup
|
||||
ai -.optional adapter calls.-> assets
|
||||
|
||||
meta --> core
|
||||
meta --> bankrec
|
||||
meta --> reports
|
||||
meta --> dashboard
|
||||
meta --> followup
|
||||
meta --> assets
|
||||
meta --> budget
|
||||
meta --> ai
|
||||
meta -.transitional only.-> migration
|
||||
```
|
||||
|
||||
### 2.2 Sub-Module Responsibilities
|
||||
|
||||
| Sub-module | Replaces | Owns | Phase |
|
||||
|---|---|---|---|
|
||||
| `fusion_accounting_core` | `accountant` (menu glue), shared bits of `account_accountant` | Shared field declarations on `account.move`/`account.bank.statement.line` (deferred fields, signing user), `fusion.fiscal.year`, lock-date wizard, security groups, settings page, `analytic_mixin` shared ownership | Phase 0 |
|
||||
| `fusion_accounting_bank_rec` | `account_accountant` bank rec widget + `account_accountant/wizard/account_auto_reconcile_wizard.py` | OWL bank-rec widget, `fusion.reconcile.engine`, auto-reconcile wizard, reconcile model extensions | Phase 1 |
|
||||
| `fusion_accounting_reports` | `account_reports` (entire 618-file engine + reports) | `fusion.account.report`, `fusion.account.report.line`, PDF templates, OWL report viewer, P&L/BS/TB/GL/Aged/Partner/CashFlow/Executive Summary | Phase 2 |
|
||||
| `fusion_accounting_dashboard` | `account_accountant` journal dashboard, `accountant/data/account_accountant_data.xml`, digest | Journal kanban, digest tiles, "Needs Attention" data shape | Phase 3 |
|
||||
| `fusion_accounting_followup` | `account_followup` | `fusion.followup.line`, follow-up workflow, multi-level reminders | Phase 5 |
|
||||
| `fusion_accounting_assets` | `account_asset` | `fusion.asset`, `fusion.asset.group`, depreciation engine, asset-register report | Phase 6 |
|
||||
| `fusion_accounting_budget` | `account_budget` | `fusion.budget`, budget-vs-actual report | Phase 6 |
|
||||
| `fusion_accounting_ai` | (none — original) | Existing AI orchestrator, tools, chat panel, approval workflow, scoring, rules — moved verbatim from current `fusion_accounting` | Phase 0 |
|
||||
| `fusion_accounting_migration` | (none — transitional) | Wizard that copies Enterprise-only data into fusion tables before Enterprise uninstall; safety guard that blocks Enterprise uninstall until wizard runs | Phase 0 |
|
||||
| `fusion_accounting` (meta) | (none — packaging) | Empty shell; `depends` on every sub-module so a single install gets everything | Phase 0 |
|
||||
|
||||
### 2.3 Why Split (vs. monolith)
|
||||
|
||||
- Sub-modules can be enabled per client need (a small client without payroll-style assets installs core + bank_rec + reports + ai only)
|
||||
- Each sub-module has independent test runs and CI (faster feedback loop)
|
||||
- Each sub-module's cross-version upgrade is independent — `fusion_accounting_reports` can absorb V20 changes without touching `fusion_accounting_bank_rec`
|
||||
- The AI sub-module stays cleanly separate, which makes it easy to keep using fusion's AI on top of Odoo Enterprise (when a client retains Enterprise) by installing `_ai` only
|
||||
|
||||
### 2.4 Open Sub-Module Naming Decisions
|
||||
|
||||
The meta-module retains the name `fusion_accounting` so existing client installs don't see a name change. Sub-modules use the `fusion_accounting_*` prefix consistently.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Preservation and Client Switchover Strategy
|
||||
|
||||
The single most important guarantee in this entire design: **client switchover from Odoo Enterprise to Odoo Community + fusion_accounting must lose zero accounting data**, especially bank reconciliations.
|
||||
|
||||
This section is the contract that backs that guarantee.
|
||||
|
||||
### 3.1 What Survives an Enterprise Uninstall Automatically
|
||||
|
||||
Verified by direct read of `RePackaged-Odoo/accounting/account/` source. These models and fields live in the Community `account` module and are unaffected by any Enterprise uninstall:
|
||||
|
||||
| Data | Storage | Verified Location |
|
||||
|---|---|---|
|
||||
| Bank reconciliation links | `account.partial.reconcile` | `account/models/account_partial_reconcile.py` |
|
||||
| Full reconciliation markers | `account.full.reconcile` | `account/models/account_partial_reconcile.py` |
|
||||
| Bank statement lines + `is_reconciled` flag | `account.bank.statement.line` | `account/models/account_bank_statement_line.py` |
|
||||
| Invoices, bills, payments | `account.move`, `account.payment` | `account/models/account_move.py`, `account_payment.py` |
|
||||
| Journal entries + lines | `account.move`, `account.move.line` | `account/models/account_move_line.py` |
|
||||
| Chart of accounts | `account.account` | `account/models/account_account.py` |
|
||||
| Taxes | `account.tax` | `account/models/account_tax.py` |
|
||||
| Journals | `account.journal` | `account/models/account_journal.py` |
|
||||
| Partners | `res.partner` | `base` |
|
||||
| Reconciliation rule base | `account.reconcile.model` | `account/models/account_reconcile_model.py` |
|
||||
| `checked` (Reviewed) flag on moves | `account.move.checked` | `account/models/account_move.py` line 315 |
|
||||
|
||||
**Critical observation about bank reconciliation in Odoo 19:** The Enterprise `account_accountant` module does **not** define a `bank.rec.widget` Python model in V19. The bank-rec widget is implemented entirely as frontend OWL components in `account_accountant/static/src/components/bank_reconciliation/`, with a thin `BankReconciliationService` (`bank_reconciliation_service.js`) that calls Community ORM methods directly. There is no Enterprise-side persistent storage for the widget. When the widget is removed (Enterprise uninstall), the underlying `account.partial.reconcile` rows are untouched; fusion's replacement widget reads the same rows and shows every historical reconciliation as already-matched.
|
||||
|
||||
(The Work-in-Progress code at `Work in Progress/fusion_accounting/models/bank_rec_widget.py` uses the V17/V18 architecture where `bank.rec.widget` was a `_auto = False` Python model. That architecture was removed in V19. Our Phase 1 implementation must match V19 architecture.)
|
||||
|
||||
**Verified Enterprise uninstall hook safety**: `account_accountant/__init__.py` line 32-42 only revokes security group assignments. There are zero destructive DB operations in the uninstall hook.
|
||||
|
||||
**Verified absence of cascade hazards**: grep for `ondelete='cascade'` in `account_accountant/models/` returns zero matches. No Enterprise model deletion can cascade-delete a reconciliation.
|
||||
|
||||
### 3.2 What Is Lost on Enterprise Uninstall (Without Mitigation)
|
||||
|
||||
| Enterprise-owned data | Importance | Mitigation Strategy |
|
||||
|---|---|---|
|
||||
| `account.fiscal.year` records (fiscal year closing definitions) | Medium | Migration wizard → `fusion.fiscal.year` |
|
||||
| `account.asset` records + asset-line links on moves | High if assets used | Migration wizard → `fusion.asset` |
|
||||
| `account.loan` records | Low (rare) | Migration wizard → `fusion.loan` (Phase 7+) |
|
||||
| Budget records | Medium if used | Migration wizard → `fusion.budget` |
|
||||
| Follow-up rule definitions + history | Medium | Migration wizard → `fusion.followup.*` |
|
||||
| `account.move.deferred_move_ids`, `deferred_original_move_ids`, `deferred_entry_type` | **High** if deferred revenue/expense used — breaks the link between original and deferred postings | **Shared-field ownership** in `fusion_accounting_core` |
|
||||
| `account.move.signing_user` (audit signer) | Medium | **Shared-field ownership** |
|
||||
| `account.move.payment_state_before_switch` | Throwaway (technical) | Ignore |
|
||||
| `account.reconcile.model.created_automatically` | Throwaway (single boolean) | Shared-field ownership in `_bank_rec` |
|
||||
| `account.bank.statement.line.cron_last_check` | Throwaway (technical) | Ignore |
|
||||
| Report XML records (P&L, BS structure) | None — reference data, not client data | fusion ships its own equivalents in `_reports` |
|
||||
| Enterprise-only menus, actions | None — UI only | fusion installs its own |
|
||||
|
||||
### 3.3 Mitigation Pattern A: Shared-Field Ownership
|
||||
|
||||
For Enterprise-added fields on Community models (the `deferred_*`, `signing_user`, `created_automatically` fields), `fusion_accounting_core` declares **identical** field definitions with the **same** relation table names:
|
||||
|
||||
```python
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
deferred_move_ids = fields.Many2many(
|
||||
comodel_name='account.move',
|
||||
relation='account_move_deferred_rel', # identical relation table to Enterprise
|
||||
column1='original_move_id',
|
||||
column2='deferred_move_id',
|
||||
copy=False,
|
||||
)
|
||||
deferred_original_move_ids = fields.Many2many(
|
||||
comodel_name='account.move',
|
||||
relation='account_move_deferred_rel',
|
||||
column1='deferred_move_id',
|
||||
column2='original_move_id',
|
||||
copy=False,
|
||||
)
|
||||
deferred_entry_type = fields.Selection(
|
||||
selection=[('expense', 'Deferred Expense'), ('revenue', 'Deferred Revenue')],
|
||||
copy=False,
|
||||
)
|
||||
signing_user = fields.Many2one(comodel_name='res.users', copy=False)
|
||||
payment_state_before_switch = fields.Char(copy=False)
|
||||
```
|
||||
|
||||
**Mechanism**: Odoo's module registry tracks every module that declares a given field on a given model. When `account_accountant` uninstalls, Odoo only drops the column (or relation table) if no other installed module also declares it. Because `fusion_accounting_core` declares these identically, Odoo retains the column/table. Existing data values are preserved row-by-row.
|
||||
|
||||
**Caveat**: this pattern creates a schema dependency on Enterprise's choices. If Odoo ever renames `account_move_deferred_rel` in V20, both the Enterprise and fusion versions of that field break together — the migration is just `ALTER TABLE ... RENAME` in our migration script. We accept this risk because the alternative (renaming to fusion-namespaced fields) requires a much heavier migration of every existing row.
|
||||
|
||||
### 3.4 Mitigation Pattern B: Pre-Uninstall Migration Wizard
|
||||
|
||||
For Enterprise-only models (`account.asset`, `account.fiscal.year`, `account.loan`, budgets, followups), `fusion_accounting_migration` provides a wizard accessible from Settings → Fusion Accounting → Migrate from Enterprise.
|
||||
|
||||
The wizard:
|
||||
|
||||
1. Detects which Enterprise modules are installed
|
||||
2. For each detected module, checks the corresponding fusion module is also installed (and prompts to install if missing)
|
||||
3. Shows a preview: row counts per Enterprise table that will be migrated, listing target fusion table for each
|
||||
4. On confirm, runs `INSERT INTO fusion_<table> SELECT ... FROM <enterprise_table>` for each migration step, preserving primary keys and `ir.model.data` xml_ids
|
||||
5. Generates a migration report (record counts, any rows that failed validation, warnings)
|
||||
6. Marks each Enterprise table as "migrated" via an `ir.config_parameter` flag (`fusion_accounting.migration.<module>.completed`)
|
||||
7. Re-running the wizard is idempotent: already-migrated tables are skipped unless explicitly re-migrated
|
||||
|
||||
A separate **safety guard** in `fusion_accounting_migration` overrides `ir.module.module.button_immediate_uninstall` for Enterprise accounting modules; if the migration flag for that module is False and it has data, the uninstall is blocked with a UserError linking to the wizard.
|
||||
|
||||
### 3.5 Switchover Protocol (the operator workflow)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
start[Client on Odoo 19 Enterprise] --> step1["Install fusion_accounting meta-module<br/>while Enterprise still running"]
|
||||
step1 --> step2["fusion_accounting_core declares shared fields<br/>Odoo registers dual ownership for deferred_*, signing_user, etc."]
|
||||
step2 --> step3["Open Settings → Fusion Accounting → Migrate from Enterprise"]
|
||||
step3 --> step4["Wizard shows preview: row counts per table"]
|
||||
step4 --> step5["Operator confirms"]
|
||||
step5 --> step6["Wizard copies asset, fiscal year, loan, budget, followup rows<br/>into fusion tables"]
|
||||
step6 --> step7["Wizard generates migration report"]
|
||||
step7 --> step8["Operator reviews report"]
|
||||
step8 --> step9["Operator triggers Enterprise uninstall in dep-safe order:<br/>account_reports → account_followup → account_asset →<br/>account_budget → account_loans → account_accountant → accountant"]
|
||||
step9 --> step10["Safety guard verifies migration flags before each uninstall"]
|
||||
step10 --> done["Done: Client on Community + fusion_accounting<br/>Bank recs intact, deferred links preserved,<br/>migrated data accessible via fusion menus"]
|
||||
```
|
||||
|
||||
### 3.6 Empirical Verification Test (Phase 0 deliverable)
|
||||
|
||||
The shared-field-ownership analysis and the inventory of "what survives" is based on reading source. Strong, but not conclusive. **Phase 0 includes a one-time empirical test**:
|
||||
|
||||
1. Provision a throwaway Odoo 19 Enterprise instance
|
||||
2. Install full Enterprise accounting stack
|
||||
3. Create representative test data:
|
||||
- 50 invoices, 30 vendor bills, mix of paid/unpaid
|
||||
- 15 bank reconciliations (full and partial)
|
||||
- 5 deferred revenue entries with `deferred_move_ids` populated
|
||||
- 3 fiscal year closings
|
||||
- 10 asset records with depreciation history
|
||||
- 2 budgets with actuals
|
||||
- Multi-currency journal entries
|
||||
- 1 cash-basis tax move
|
||||
3. Take `pg_dump` snapshot
|
||||
4. Uninstall Enterprise modules in dep-safe order **without** running the migration wizard (this is the worst-case test)
|
||||
5. Diff schema and row counts before and after
|
||||
6. Document findings in `docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md`
|
||||
7. If gaps are found vs. Section 3.2, expand the wizard scope or shared-field declarations accordingly
|
||||
|
||||
This test is a Phase 0 acceptance gate. The roadmap does not advance to Phase 1 until empirical verification confirms or expands the analysis.
|
||||
|
||||
### 3.7 Reverse-Migration Note
|
||||
|
||||
The reverse direction (client on Community + fusion adds an Enterprise subscription later) is not a hard requirement. fusion's runtime feature-gating (Section 4.4) handles the coexistence case: when Enterprise is detected, fusion's conflicting menus hide and the AI module continues running on top of Enterprise. A reverse-migration wizard can be added in Phase 7+ if a real client needs it.
|
||||
|
||||
### 3.8 Backup and Rollback
|
||||
|
||||
Every client deployment must include, before any switchover step:
|
||||
|
||||
- `pg_dump` of the live database
|
||||
- Snapshot of all installed module versions (`SELECT name, latest_version FROM ir_module_module WHERE state='installed'`)
|
||||
- Snapshot of `/mnt/extra-addons/` contents
|
||||
|
||||
Rollback procedure: restore DB from `pg_dump`, restore extra-addons from snapshot, restart Odoo. The migration wizard's "Generate Backup First" checkbox is checked by default and must be explicitly unchecked to skip.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased Roadmap
|
||||
|
||||
Each phase produces shippable value. Phase order is locked. Time estimates are rough single-engineer figures and are not binding deadlines — the user has explicitly stated "no rush, product-first".
|
||||
|
||||
### 4.1 Phase Overview
|
||||
|
||||
| Phase | Focus | Estimate | Depends On |
|
||||
|---|---|---|---|
|
||||
| 0 | Foundation, sub-module split, migration scaffold, empirical test | 1-2 wks | (none) |
|
||||
| 1 | Bank reconciliation (priority) | 3-5 wks | 0 |
|
||||
| 2 | Financial reports engine | 6-10 wks | 0 |
|
||||
| 3 | Dashboard + fiscal year + lock dates | 2-3 wks | 1, 2 |
|
||||
| 4 | Tax reports + returns | 3-5 wks | 2 |
|
||||
| 5 | Payment follow-ups | 2-3 wks | 3, 4 |
|
||||
| 6 | Assets + budgets | 3-5 wks | 5 |
|
||||
| 7+ | Optional satellites (loans, check printing, batch payment, 3-way match, EDI, SEPA, SAFT, intrastat, online sync) | per item | 6 |
|
||||
|
||||
Phases 1 and 2 can run in parallel after Phase 0 (no shared scope).
|
||||
|
||||
### 4.2 Phase 0 — Foundation
|
||||
|
||||
No user-facing features. Pure plumbing so every later phase is cheaper.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create sub-module scaffolding for `fusion_accounting_core`, `fusion_accounting_migration`, `fusion_accounting_ai`
|
||||
- Move existing AI copilot code from current `fusion_accounting/` into `fusion_accounting_ai/`. Files moved: `models/`, `services/`, `controllers/`, `wizards/`, `data/`, `static/src/`, `views/`, `security/`, `report/`, `tests/`. Update internal imports
|
||||
- Convert current `fusion_accounting/` into the meta-module: empty `__init__.py`, manifest with `depends = ['fusion_accounting_core', 'fusion_accounting_ai', ...]` (sub-modules added as later phases ship), no Python/JS/XML code of its own
|
||||
- Strip hard Enterprise deps from `fusion_accounting_ai/__manifest__.py`. Replace `account_accountant`, `account_reports`, `account_followup` with `account` (Community). Add runtime detection (Section 4.4)
|
||||
- Refactor every AI tool in `fusion_accounting_ai/services/tools/` that calls Enterprise APIs to go through an adapter layer (`services/adapters/bank_rec_adapter.py`, `reports_adapter.py`, `followup_adapter.py`). Adapters pick between Enterprise APIs (when present) and fusion native (when present) and a "feature-unavailable" stub (when neither)
|
||||
- Create `fusion_accounting_core/models/account_move.py` with shared-field declarations (Section 3.3)
|
||||
- Create `fusion_accounting_migration/` shell: empty wizard, safety guard scaffold (no migrations yet)
|
||||
- Create `tools/check_odoo_diff.sh` script that diffs two pinned Odoo source snapshots and outputs a categorized change list
|
||||
- Move security groups: `group_fusion_accounting_user/manager/admin` move from current to `fusion_accounting_core/security/`. Multi-company record rule on `fusion.accounting.session` added (currently missing per existing CLAUDE.md "Known Issues")
|
||||
- Create per-sub-module `CLAUDE.md` (factor common rules from existing `fusion_accounting/CLAUDE.md`) and `UPGRADE_NOTES.md` template
|
||||
- Run the empirical verification test (Section 3.6) on a throwaway V19 Enterprise instance
|
||||
- CI: GitHub Actions or gitea workflow that runs `pytest` per sub-module on every push
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Current AI copilot installs and runs on pure Community (no Enterprise modules present)
|
||||
- Current AI copilot still installs and runs alongside Enterprise (coexistence mode)
|
||||
- Empirical test report committed
|
||||
- All adapter calls wired (no direct Enterprise API access from AI tools)
|
||||
- CI green
|
||||
|
||||
**Risks and mitigations:**
|
||||
|
||||
- **Risk**: moving code between modules breaks existing client deployments. **Mitigation**: meta-module install upgrade hook handles model-record reassignment via `ir_model_data` updates; pre-migration script runs on first install of Phase 0
|
||||
- **Risk**: empirical test reveals gaps. **Mitigation**: scope-expand the migration wizard before declaring Phase 0 complete
|
||||
|
||||
### 4.3 Phase 1 — Bank Reconciliation
|
||||
|
||||
The user's stated priority. Replaces `account_accountant`'s bank-rec widget end-to-end.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_bank_rec/` sub-module
|
||||
- **Frontend (mirror zone)**: build `static/src/components/bank_reconciliation/` mirroring the file layout of `account_accountant/static/src/components/bank_reconciliation/` (`kanban_controller`, `kanban_renderer`, `bank_reconciliation_service`, `apply_amount`, `bankrec_form_dialog`, `button`, `button_list`, `chatter`, `file_uploader`, `line_info_pop_over`, `line_to_reconcile`, `list_view`, `quick_create`, `reconciled_line_name`, `search_dialog`, `statement_line`, `statement_summary`). Mirror is structural — class names, file names, OWL component boundaries — not copy-paste. Implementation written fresh against documented Odoo behavior
|
||||
- **Backend (abstract zone)**: `models/fusion_reconcile_engine.py` containing the matching algorithm (FIFO, partial reconcile, write-off lines, exchange-rate diff posting, tax splits). Original implementation against documented requirements. Operates on Community `account.partial.reconcile`
|
||||
- `models/fusion_reconcile_model.py` extending Community `account.reconcile.model` with auto-rules, partner mapping, journal mapping. Shared-field ownership for `created_automatically`
|
||||
- `wizards/auto_reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_auto_reconcile_wizard.py`
|
||||
- `wizards/reconcile_wizard.py` clean-room rewrite of `account_accountant/wizard/account_reconcile_wizard.py`
|
||||
- `views/bank_rec_widget_views.xml` defines the action that opens the OWL widget; `views/account_reconcile_model_views.xml` for rule editing
|
||||
- Menu: "Bank Reconciliation" under fusion accounting menu, with feature-gate (hidden if `account_accountant` installed)
|
||||
- AI integration: existing AI tools `get_unreconciled_bank_lines`, `find_similar_bank_lines`, `get_bank_line_details`, `find_missing_itc_bills`, `find_duplicate_bills`, `get_overdue_invoices` get refactored to call fusion's bank rec engine via `fusion_accounting_ai/services/adapters/bank_rec_adapter.py`. The Tier 3 tools `create_vendor_bill`, `register_bill_payment`, `create_expense_entry` keep their existing logic (they write to Community `account.move`)
|
||||
- Migration: wizard validates `account.partial.reconcile` row count is preserved across switchover (read-only check, no migration needed)
|
||||
- Tests:
|
||||
- Unit (engine): matching correctness with fixtures (single partner, multi-partner, multi-currency, partial, exchange diff, write-off, tax split)
|
||||
- Integration: install + create statement + reconcile via UI + assert `account.partial.reconcile` rows
|
||||
- Tour (JS): smoke through the full bank rec workflow
|
||||
- Migration: install Enterprise, create 10 reconciliations, install fusion, uninstall Enterprise, assert reconciliations visible in fusion widget
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Community + fusion_accounting user can reconcile bank statements with feature parity to Enterprise
|
||||
- All Phase 1 tests passing
|
||||
- Migration round-trip (Enterprise → fusion) preserves every reconciliation
|
||||
- AI tools work against fusion bank rec engine
|
||||
|
||||
### 4.4 Phase 2 — Financial Reports Engine
|
||||
|
||||
The largest phase. Replaces `account_reports` (618 files).
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_reports/` sub-module
|
||||
- **Backend (abstract zone)**: `models/fusion_account_report.py` defining `fusion.account.report` and `fusion.account.report.line`. Generic engine that takes a report definition (sections, filters, computation rules) and produces report rows from `account.move.line` data. Original computation kernel — does not copy `account_reports`'s `account_report.py`
|
||||
- **Backend (mirror zone)**: report definition records mirror Odoo's data files. Files: `data/balance_sheet.xml`, `data/profit_and_loss.xml`, `data/cash_flow_report.xml`, `data/general_ledger.xml`, `data/trial_balance.xml`, `data/aged_partner_balance.xml`, `data/partner_ledger.xml`, `data/executive_summary.xml`, `data/sales_report.xml`, `data/multicurrency_revaluation_report.xml`, `data/bank_reconciliation_report.xml`, `data/deferred_reports.xml`, `data/journal_report.xml`, `data/customer_statement.xml`. XML structure follows Odoo's so V20 ports are diff-and-apply
|
||||
- **Frontend (mirror zone)**: `static/src/components/` mirrors `account_reports/static/src/components/` — filters bar, comparison toggle, drill-down, foldable sections, footnotes
|
||||
- **PDF export**: QWeb templates in `report/` mirror Odoo's `data/pdf_export_templates.xml` and `data/customer_reports_pdf_export_templates.xml`. Asset bundle `fusion_accounting_reports.assets_pdf_export` defined in manifest
|
||||
- Performance: denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post). Drill-down lazy-loads line detail. Per-(company, period, filter_hash) cache invalidated on `account.move.line` write
|
||||
- Multi-company, multi-currency, cash-basis toggle — all handled by the engine
|
||||
- AI integration: tools `get_profit_loss`, `get_balance_sheet`, `get_trial_balance`, `get_aged_receivables`, `get_aged_payables`, `get_partner_ledger`, `answer_financial_question` refactored via `reports_adapter.py`
|
||||
- Migration: report XML records are reference data, not client data. fusion ships its own equivalent records; no migration of report definitions needed. Existing journal entry data (which the reports compute from) is in Community `account` and untouched
|
||||
- Tests:
|
||||
- Unit (engine): SQL-fixture comparisons (compute report → compare against hand-rolled SQL) for every standard report, every filter combination
|
||||
- Integration: install + post entries + open report + assert numbers
|
||||
- Multi-currency: single + multi + revaluation period
|
||||
- Performance: 1k / 10k / 100k journal lines, assert P95 latency under 5s
|
||||
- PDF: render every report to PDF, assert no QWeb errors
|
||||
- Tour: smoke through report viewer with filters
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- All 14 standard reports rendering correctly (numerical match against SQL fixtures)
|
||||
- PDF export working for every report
|
||||
- Performance targets met
|
||||
- AI tools backed by fusion reports
|
||||
|
||||
### 4.5 Phase 3 — Dashboard + Fiscal Year + Lock Dates
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_dashboard/` sub-module
|
||||
- **Journal kanban dashboard**: mirror layout of `account_accountant/views/account_journal_dashboard_views.xml`. Computed metrics in `models/account_journal.py` extending Community `account.journal` with kanban-state fields (counts, totals, action buttons). Original computation; mirror UI
|
||||
- `models/fusion_fiscal_year.py` defining `fusion.fiscal.year` (replaces `account.fiscal.year`)
|
||||
- Fiscal year wizard: closing workflow, period locks, initial-balance carry-forward
|
||||
- Lock date wizard: clean-room rewrite of `account_accountant/wizard/account_change_lock_date.py`. Operates on Community `account.lock_exception` model (verified at `account/models/account_lock_exception.py`)
|
||||
- Digest tile contributions: extend `mail.digest` with fusion accounting metrics (revenue, expense, AR, AP)
|
||||
- "Needs Attention" panel — connect data already returned by current AI dashboard endpoint to a frontend rendering. Dashboard endpoint (currently in `fusion_accounting_ai/controllers/`) moves to `fusion_accounting_dashboard/controllers/`; AI module's dashboard tiles call dashboard's endpoint via adapter
|
||||
- Tests:
|
||||
- Journal dashboard kanban metrics match expected values for fixtures
|
||||
- Fiscal year close locks subsequent edits
|
||||
- Lock date wizard prevents posting before lock date
|
||||
- Digest renders without errors
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Journal dashboard at parity with Enterprise
|
||||
- Fiscal year management functional
|
||||
- Lock dates enforced
|
||||
- Digest emails delivering
|
||||
|
||||
### 4.6 Phase 4 — Tax Reports + Returns
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Build on Phase 2 reports engine; tax reports are specialized `fusion.account.report` records
|
||||
- Generic tax report (`data/generic_tax_report.xml`) with country-specific overrides
|
||||
- Canadian HST: unify the existing HST workflow in `fusion_accounting_ai` (currently in `services/prompts/domain_prompts.py` and tool functions) with the new tax report engine. The existing `find_missing_itc_bills`, `get_overdue_invoices`, etc. tools call into the tax report
|
||||
- `fusion.account.return` model (replaces `account.return` from `account_reports`) tracking tax return drafts, submitted state, payment status
|
||||
- Return creation wizard, return submission wizard, return generic payment wizard — clean-room rewrites of the corresponding `account_reports` wizards
|
||||
- Tax closing entries (move generation on tax period close)
|
||||
- Tests:
|
||||
- Tax report numbers match SQL fixtures
|
||||
- Return workflow: draft → review → submitted → paid
|
||||
- HST 4-phase workflow (per existing CLAUDE.md) end-to-end
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Generic tax report functional
|
||||
- Canadian HST workflow runs through fusion (no Enterprise dependency)
|
||||
- Return tracking working
|
||||
|
||||
### 4.7 Phase 5 — Payment Follow-ups
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_followup/` sub-module
|
||||
- `models/fusion_followup_line.py` (replaces `account_followup.followup.line`)
|
||||
- `models/res_partner.py` extends `res.partner` with follow-up level, last reminder date, dunning history
|
||||
- `models/account_move.py` extends `account.move` with follow-up state (overdue days, last reminder)
|
||||
- Multi-level reminder workflow: each level has email template, days delay, optional SMS, optional `mail.activity`
|
||||
- `wizards/followup_send_wizard.py` for manual sends; cron for automatic
|
||||
- Follow-up report (PDF): clean-room template
|
||||
- AI integration: `fusion_accounting_ai` adds tools `draft_followup_message_for_partner`, `send_followup_to_overdue_partners` calling the followup engine via adapter
|
||||
- Migration: wizard copies `account_followup.followup.line` and partner-level follow-up state into `fusion.followup.line` and shared-field-owned partner fields
|
||||
- Tests:
|
||||
- Multi-level escalation
|
||||
- Email template rendering
|
||||
- SMS delivery (mock)
|
||||
- AI-drafted message quality (snapshot tests)
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Multi-level dunning working
|
||||
- Migration from `account_followup` preserves history
|
||||
|
||||
### 4.8 Phase 6 — Assets + Budgets
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Create `fusion_accounting_assets/` sub-module
|
||||
- `models/fusion_asset.py` (replaces `account.asset`)
|
||||
- `models/fusion_asset_group.py` (replaces `account.asset.group`)
|
||||
- Depreciation engine: linear, declining, custom schedules. Original implementation
|
||||
- `wizards/asset_modify.py` for revaluation, sale, disposal — clean-room rewrite
|
||||
- Asset register report integrates with Phase 2 reports engine
|
||||
- Migration wizard copies `account.asset` rows + line links on moves
|
||||
- Create `fusion_accounting_budget/` sub-module
|
||||
- `models/fusion_budget.py` (replaces `budget.analytic`)
|
||||
- Budget vs actual report integrates with Phase 2 reports engine
|
||||
- Migration wizard copies budget records
|
||||
- Tests for both
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Asset depreciation schedules computed correctly
|
||||
- Disposal generates correct GL entries
|
||||
- Budget variance report functional
|
||||
|
||||
### 4.9 Phase 7+ — Optional Satellites
|
||||
|
||||
Not scheduled. Each is its own brainstorming → spec → plan → implementation cycle when a real client needs it. Candidate satellite modules:
|
||||
|
||||
- `fusion_accounting_loans` — loan amortization
|
||||
- `fusion_accounting_check_printing` — check printing
|
||||
- `fusion_accounting_batch_payment` — batch payments
|
||||
- `fusion_accounting_3way_match` — purchase 3-way match
|
||||
- `fusion_accounting_edi` — UBL/CII e-invoicing
|
||||
- `fusion_accounting_sepa` — SEPA direct debit + credit transfer
|
||||
- `fusion_accounting_saft` — SAFT export
|
||||
- `fusion_accounting_intrastat` — intrastat report
|
||||
- `fusion_accounting_iso20022` — ISO 20022 payment files
|
||||
- `fusion_accounting_online_sync` — online bank sync (Yodlee/Plaid integration)
|
||||
|
||||
### 4.10 Per-Phase Deliverables (uniform)
|
||||
|
||||
Each phase produces:
|
||||
|
||||
1. A separate **design document** in `docs/superpowers/specs/YYYY-MM-DD-fusion-accounting-phase-N-*-design.md` (brainstormed in its own session)
|
||||
2. A separate **implementation plan** via the `writing-plans` skill
|
||||
3. Working code with passing tests
|
||||
4. Entry in the sub-module's `UPGRADE_NOTES.md` listing Odoo source files referenced and intentional deltas
|
||||
5. Coverage in `fusion_accounting_migration` if the phase replaces an Enterprise data-bearing model
|
||||
6. Manual QA checklist (install, migrate, smoke, uninstall) committed to the sub-module
|
||||
7. Update to the meta-module `__manifest__.py` adding the new sub-module to its `depends`
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture Rules
|
||||
|
||||
These rules apply to every sub-module and every phase. They are the discipline that keeps V19→V20 upgrades mechanical and prevents the WIP-style descent into copied code with stale architecture.
|
||||
|
||||
### 5.1 The Hybrid Split
|
||||
|
||||
Every sub-module has two zones with different rules:
|
||||
|
||||
**Mirror zone** (follows Odoo structure 1:1):
|
||||
|
||||
- XML view definitions and xpath targets
|
||||
- Frontend OWL component file layout, service registration, widget props
|
||||
- PDF/QWeb templates: structure, CSS class names
|
||||
- Wizard flows: step order, field names where they appear in views
|
||||
- Asset bundle declarations in manifests
|
||||
|
||||
**Locations**: `views/`, `static/src/components/`, `report/` QWeb templates, `wizards/*_views.xml`, `__manifest__.py` asset bundles
|
||||
|
||||
**Abstract zone** (our own design, insulated from Odoo internals):
|
||||
|
||||
- Core algorithms: matching, aggregation, computation, depreciation
|
||||
- Data access helpers
|
||||
- Business validation, approval flows
|
||||
- AI integration adapters
|
||||
- Engine classes (e.g. `fusion_reconcile_engine.py`)
|
||||
|
||||
**Locations**: `models/fusion_*_engine.py`, `services/`, `controllers/` (business logic only — request routing is mirror-zone)
|
||||
|
||||
**Rule of thumb**: if Odoo refactors it every release, mirror it. If it's been stable for a decade (FIFO matching, accrual rules, depreciation math), abstract it.
|
||||
|
||||
### 5.2 Naming Conventions
|
||||
|
||||
| Thing | Convention | Example |
|
||||
|---|---|---|
|
||||
| Model `_name` | `fusion.*` prefix always | `fusion.bank.rec.widget`, `fusion.account.report`, `fusion.fiscal.year` |
|
||||
| Model `_inherit` on Community | Keep `account.*` (no rename) | `class AccountMove(models.Model): _inherit = 'account.move'` |
|
||||
| Model `_inherit` on Enterprise | **Forbidden** — duplicate fields via shared-field-ownership instead | n/a |
|
||||
| Python class names | `Fusion` prefix for new models | `FusionBankRecWidget`, `FusionAccountReport` |
|
||||
| Table names (auto-derived) | Follows model prefix | `fusion_bank_rec_widget`, `fusion_account_report` |
|
||||
| XML record IDs | `fusion_*` prefix | `<record id="fusion_view_bank_rec_form">` |
|
||||
| Menu IDs | `fusion_menu_*` prefix | Avoids collision with `account_menu_*` |
|
||||
| Action IDs | `fusion_action_*` | Same |
|
||||
| Controller routes | `/fusion_accounting/*` | Already in use; carries forward |
|
||||
| Security groups | `group_fusion_*` | Already in use |
|
||||
| Field names on inherited Community models | Identical to Enterprise if shared-field-owned; otherwise `x_fusion_*` prefix | `deferred_move_ids` (shared), `x_fusion_ai_confidence` (our own) |
|
||||
| CSS/SCSS classes | `.fusion_*` or `.o_fusion_*` | Avoids Bootstrap/Odoo collision |
|
||||
| `ir.config_parameter` keys | `fusion_accounting.*` | Already in use |
|
||||
|
||||
### 5.3 Coexistence Detection
|
||||
|
||||
Every sub-module that replaces an Enterprise feature must detect Enterprise at install time and at runtime, and feature-gate accordingly.
|
||||
|
||||
**Helper function** (lives in `fusion_accounting_core/models/ir_module_module.py`):
|
||||
|
||||
```python
|
||||
class IrModuleModule(models.Model):
|
||||
_inherit = "ir.module.module"
|
||||
|
||||
@api.model
|
||||
def _fusion_is_enterprise_accounting_installed(self):
|
||||
return bool(self.sudo().search_count([
|
||||
('name', 'in', ['account_accountant', 'account_reports', 'accountant']),
|
||||
('state', '=', 'installed'),
|
||||
]))
|
||||
```
|
||||
|
||||
**Three coexistence modes per sub-module**, configurable in Settings → Fusion Accounting → Integration Mode:
|
||||
|
||||
1. **Replace** (default when Enterprise absent): fusion menus visible, fusion views primary, fusion workflows active
|
||||
2. **Augment** (default when Enterprise present): fusion menus hidden, fusion widgets disabled, fusion AI module continues to call Enterprise APIs via adapters
|
||||
3. **Force-replace** (manual): fusion menus visible alongside Enterprise (operator's choice — risk of confusion, used during migration)
|
||||
|
||||
Menu visibility achieved via `groups` attribute referencing a dynamically-computed group (`group_fusion_show_menus_when_enterprise_absent`), implemented as a `@api.depends` computed field on `res.users` that recomputes membership when modules change state.
|
||||
|
||||
### 5.4 Zero Hard Enterprise Dependencies
|
||||
|
||||
After Phase 0:
|
||||
|
||||
- `fusion_accounting_core/__manifest__.py`: `depends = ['account', 'mail', 'web_tour']`
|
||||
- `fusion_accounting_ai/__manifest__.py`: `depends = ['fusion_accounting_core']` plus `external_dependencies` for `anthropic`, `openai`
|
||||
- Every other `fusion_accounting_*/__manifest__.py`: `depends = ['fusion_accounting_core']` plus fusion siblings as needed (e.g., `_followup` depends on `_reports`)
|
||||
|
||||
**No `fusion_accounting_*` module may have `account_accountant`, `account_reports`, `accountant`, `account_followup`, `account_asset`, `account_budget`, `account_loans`, `account_3way_match`, `account_check_printing`, `account_batch_payment`, `account_iso20022`, `account_intrastat`, `account_saft`, `account_sepa_direct_debit`, `account_online_synchronization`, or any `account_edi_*` in its `depends`.**
|
||||
|
||||
Runtime detection (Section 5.3) replaces compile-time dependency.
|
||||
|
||||
### 5.5 Canonical Sub-Module Directory Layout
|
||||
|
||||
```
|
||||
fusion_accounting_<feature>/
|
||||
├── __manifest__.py
|
||||
├── __init__.py
|
||||
├── CLAUDE.md # module-specific context for Cursor agent
|
||||
├── UPGRADE_NOTES.md # Odoo version deltas absorbed
|
||||
├── README.md # operator-facing install/configure/troubleshoot
|
||||
├── docs/
|
||||
│ └── odoo_diff/ # snapshots of relevant Odoo source for diffing
|
||||
│ └── v19/
|
||||
│ └── account_accountant__bank_reconciliation_service.js
|
||||
├── controllers/
|
||||
│ └── __init__.py
|
||||
├── data/
|
||||
├── demo/
|
||||
├── i18n/
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ ├── fusion_<feature>_engine.py # abstract zone: core algorithm
|
||||
│ ├── account_<x>.py # mirror zone: inherits Community model
|
||||
│ └── fusion_<y>.py # mirror zone: our own models
|
||||
├── report/
|
||||
├── security/
|
||||
│ ├── ir.model.access.csv
|
||||
│ └── <feature>_security.xml
|
||||
├── services/ # AI / heavy business logic
|
||||
├── static/
|
||||
│ ├── description/
|
||||
│ │ ├── icon.png
|
||||
│ │ └── index.html
|
||||
│ └── src/
|
||||
│ ├── components/ # mirror zone: OWL components
|
||||
│ ├── scss/
|
||||
│ ├── services/ # frontend services
|
||||
│ └── views/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_<feature>_engine.py # abstract zone unit tests
|
||||
│ ├── test_<feature>_integration.py # full-stack integration tests
|
||||
│ ├── test_migration.py # Enterprise → fusion round-trip
|
||||
│ └── tours/
|
||||
├── views/
|
||||
├── wizards/
|
||||
└── migrations/ # Odoo version migration scripts (XX.0.x.y.z)
|
||||
└── 19.0.1.0.0/
|
||||
├── pre-migration.py
|
||||
└── post-migration.py
|
||||
```
|
||||
|
||||
### 5.6 Odoo 19 Gotchas (carried forward, factored across CLAUDE.md files)
|
||||
|
||||
The current `fusion_accounting/CLAUDE.md` documents Odoo 19-specific traps that have already cost time. All carry forward:
|
||||
|
||||
- Search views: no `string` attribute on `<search>` or `<group>`; group-by filters need `domain="[]"`; `<separator/>` before `<group>`
|
||||
- OWL client actions: `static props = ["*"]` (accept any), not `static props = []` (accept none)
|
||||
- OWL rich HTML: `markup()` and `t-out` unreliable in Odoo 19; use `onMounted` + `onPatched` + direct `innerHTML`
|
||||
- Cron `safe_eval`: no `import` statements; use `datetime.datetime.now()` not `from datetime import datetime`
|
||||
- `read_group()` deprecated → use `_read_group()`
|
||||
- `ir_config_parameter` Selection field migrations: stored DB value must match new options or Settings page crashes
|
||||
- `implied_ids` on groups only applies to newly-added users — existing users need SQL backfill
|
||||
- `TransientModel` in controllers: use `.new({...})` not `.create({...})`
|
||||
- HTTP routes: `type="jsonrpc"`, not `type="json"` (deprecated)
|
||||
- `res.config.settings`: only boolean/integer/float/char/selection/many2one/datetime; no Date fields
|
||||
- `res.groups`: no `users` field, no `category_id` field
|
||||
- Search views: no `group expand="0"` syntax
|
||||
- SCSS imports: `@import "./partial"` is forbidden in Odoo 19 custom SCSS; register every SCSS file as a separate entry in `web.assets_backend`
|
||||
- Card styling: don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)`; use Odoo's kanban explicit-hex pattern with custom-property tokens
|
||||
- Dark mode: branch on `$o-webclient-color-scheme` at SCSS compile time, not runtime DOM class
|
||||
- Asset bundle cache busting: bump manifest version + `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` if needed
|
||||
|
||||
These rules belong in each sub-module's `CLAUDE.md` (the relevant subset) plus the workspace-root `CLAUDE.md` (common rules).
|
||||
|
||||
### 5.7 Manifest Versioning and Branch Strategy
|
||||
|
||||
- Per-sub-module manifest: `'version': 'XX.0.x.y.z'` where XX is the Odoo version (e.g., `19.0.1.0.0` for V19, first release)
|
||||
- Bump `XX` on Odoo version change (V19 → V20 → V21)
|
||||
- Bump `x` on major feature additions within an Odoo version
|
||||
- Bump `y` on minor features and bug fixes
|
||||
- Bump `z` on hotfixes
|
||||
- Git branches: `main-v19`, `main-v20`, etc. Each client deployment is pinned to one branch
|
||||
- Release tags: `<sub-module>/v19.0.1.0.0` per sub-module per release
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Version Upgrade Workflow
|
||||
|
||||
This section is the user's stated top concern: how to keep porting Enterprise changes forward each year without it becoming a rewrite project.
|
||||
|
||||
### 6.1 Snapshot Discipline
|
||||
|
||||
Maintain one pinned snapshot of the relevant Odoo source per Odoo version:
|
||||
|
||||
```
|
||||
/Users/gurpreet/Github/RePackaged-Odoo/
|
||||
├── accounting-v19/ # current snapshot (already in place at accounting/)
|
||||
├── accounting-v20/ # added when V20 ships
|
||||
├── accounting-v21/ # added when V21 ships
|
||||
```
|
||||
|
||||
Older snapshots are never deleted — they are the diff source for upgrade work.
|
||||
|
||||
### 6.2 Annual Upgrade Ritual
|
||||
|
||||
When Odoo V<N+1> ships:
|
||||
|
||||
1. Add the snapshot folder
|
||||
2. For each fusion sub-module:
|
||||
- Run `tools/check_odoo_diff.sh <enterprise_module> v<N> v<N+1> > reports/v<N+1>_<module>_diff.md`
|
||||
- Manually classify each change in the diff:
|
||||
- `[MIRROR]` — apply the same hunk to fusion's mirror-zone files (mechanical)
|
||||
- `[ABSTRACT]` — verify the Odoo public API surface our adapter uses still works; update the adapter if signatures changed
|
||||
- `[NEW FEATURE]` — decide port or defer
|
||||
- `[BUG FIX]` — port (usually cheap)
|
||||
- `[REMOVED]` — clean up our equivalent
|
||||
- Apply mirror-zone hunks (these are usually direct `patch -p1` operations)
|
||||
- Write Odoo version migration scripts in `migrations/<N+1>.0.0.0.0/` for any data-shape changes
|
||||
- Update `UPGRADE_NOTES.md`
|
||||
- Run all tests
|
||||
3. Tag releases on `main-v<N+1>` branch
|
||||
4. Pilot upgrade on one client first; ratchet outward
|
||||
|
||||
### 6.3 `UPGRADE_NOTES.md` Template
|
||||
|
||||
```markdown
|
||||
# UPGRADE_NOTES — fusion_accounting_bank_rec
|
||||
|
||||
## V19.0.1.0.0 (initial)
|
||||
- Ported from: account_accountant V19 (snapshot date 2026-04-18)
|
||||
- Mirror sources:
|
||||
- account_accountant/static/src/components/bank_reconciliation/* → fusion_accounting_bank_rec/static/src/components/bank_reconciliation/*
|
||||
- account_accountant/wizard/account_auto_reconcile_wizard.py → fusion_accounting_bank_rec/wizards/auto_reconcile_wizard.py (clean-room)
|
||||
- Abstract zone:
|
||||
- models/fusion_reconcile_engine.py — original implementation
|
||||
- Intentional deltas from Odoo:
|
||||
- AI hook in reconcile step (calls fusion_accounting_ai.suggest_match adapter)
|
||||
- Different default colour palette (SCSS var overrides)
|
||||
|
||||
## V20.0.x.y.z (planned, not yet shipped)
|
||||
- Odoo changes account_accountant V19 → V20 absorbed:
|
||||
- [MIRROR] kanban_renderer.js: column layout changed, applied identical hunk
|
||||
- [ABSTRACT] account.reconcile.model._apply_lines_for_bank_widget signature changed — updated adapter
|
||||
- [NEW FEATURE] batch-reconcile-across-journals — deferred to V20.1
|
||||
- Migration scripts:
|
||||
- migrations/20.0.0.0.0/pre-migration.py: rename column foo → bar
|
||||
```
|
||||
|
||||
### 6.4 `tools/check_odoo_diff.sh` Specification
|
||||
|
||||
The script lives at `fusion_accounting/tools/check_odoo_diff.sh` (workspace root, shared across sub-modules). Usage:
|
||||
|
||||
```bash
|
||||
tools/check_odoo_diff.sh <enterprise_module> <from_version> <to_version> [<output_file>]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Runs `diff -ruN /Users/gurpreet/Github/RePackaged-Odoo/accounting-<from>/<module> /Users/gurpreet/Github/RePackaged-Odoo/accounting-<to>/<module>`
|
||||
- Splits output into per-file sections
|
||||
- For each file, classifies based on file path: `views/` and `static/src/components/` and `report/` → `[MIRROR]` candidate; `models/*_engine.py`-like → `[ABSTRACT]` review; new files → `[NEW FEATURE]` review
|
||||
- Outputs a markdown report with per-file sections and classification suggestions
|
||||
- Exit code: 0 if no changes, non-zero if changes (CI can use to flag annual upgrades)
|
||||
|
||||
### 6.5 Pinning and Rollback
|
||||
|
||||
- Git: `main-v19`, `main-v20`, etc. branches in fusion repo. Each client stays on their pinned Odoo version
|
||||
- Manifest version pinned per sub-module per Odoo version
|
||||
- Client deployment: never auto-upgrade. Upgrade is a deliberate, tested, per-client migration
|
||||
- Rollback: restore DB from `pg_dump` taken before upgrade, restore `fusion_accounting_*` checkout from git tag, restart Odoo
|
||||
|
||||
### 6.6 Cross-Version Migration Scripts
|
||||
|
||||
Odoo's standard migration mechanism applies. Each sub-module has a `migrations/` folder with subfolders named after manifest versions. Scripts run automatically when the manifest version bumps in the database vs. on disk.
|
||||
|
||||
```python
|
||||
# fusion_accounting_assets/migrations/20.0.0.0.0/pre-migration.py
|
||||
def migrate(cr, version):
|
||||
# V20 renamed fusion_asset.original_value to fusion_asset.acquisition_cost
|
||||
cr.execute("ALTER TABLE fusion_asset RENAME COLUMN original_value TO acquisition_cost")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AI Integration, Testing, Documentation
|
||||
|
||||
### 7.1 AI Integration
|
||||
|
||||
The AI copilot (existing `fusion_accounting/services/`, `fusion_accounting/static/src/`, `fusion_accounting/controllers/` etc.) moves to `fusion_accounting_ai/` in Phase 0 and stays original code. What changes:
|
||||
|
||||
**Adapter pattern**: every AI tool that today calls Enterprise APIs gets routed through an adapter:
|
||||
|
||||
```
|
||||
fusion_accounting_ai/services/adapters/
|
||||
├── bank_rec_adapter.py
|
||||
├── reports_adapter.py
|
||||
├── followup_adapter.py
|
||||
├── assets_adapter.py
|
||||
└── _registry.py
|
||||
```
|
||||
|
||||
Adapter behavior (uniform pattern across all adapters):
|
||||
|
||||
```python
|
||||
class BankRecAdapter:
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def list_unreconciled_lines(self, journal_id, limit=100):
|
||||
# Prefer fusion native if installed
|
||||
if 'fusion.bank.rec.widget' in self.env.registry:
|
||||
return self.env['fusion.bank.rec.widget'].sudo().get_unreconciled(journal_id, limit)
|
||||
# Fall back to Enterprise if installed
|
||||
elif self.env['ir.module.module']._fusion_is_module_installed('account_accountant'):
|
||||
return self._enterprise_unreconciled_lines(journal_id, limit)
|
||||
# Last resort: pure Community search
|
||||
else:
|
||||
return self.env['account.bank.statement.line'].sudo().search([
|
||||
('journal_id', '=', journal_id),
|
||||
('is_reconciled', '=', False),
|
||||
], limit=limit)
|
||||
```
|
||||
|
||||
This pattern means `fusion_accounting_ai` always works, regardless of which other modules are installed. The AI tool functions in `fusion_accounting_ai/services/tools/` get refactored once in Phase 0 to call adapters; subsequent phases just enrich the adapters.
|
||||
|
||||
**New AI capabilities unlocked by native implementations**: each native phase exposes engine internals to AI tools that Enterprise didn't expose cleanly. Examples:
|
||||
|
||||
- Phase 1: AI gets access to fusion's match-confidence scores
|
||||
- Phase 2: AI can request a report computation with custom comparison periods on the fly
|
||||
- Phase 4: AI has direct access to tax-grid-by-account decomposition
|
||||
- Phase 5: AI drafts follow-up messages with full payment history context
|
||||
|
||||
**Existing AI patterns carry forward unchanged**:
|
||||
|
||||
- Tool tiering (Tier 1 / 2 / 3 with auto-promotion)
|
||||
- Provider pinning per session (Claude vs OpenAI consistency within a session)
|
||||
- Tier 3 approval flow with `pending_approval` placeholder swap on approve/reject
|
||||
- Rich-text chat output via `mdToHtml()` and `innerHTML` injection
|
||||
- Interactive `fusion-table` blocks for actionable results
|
||||
- Session ownership / multi-company record rules (the `fusion.accounting.session` rule that's currently missing gets added in Phase 0)
|
||||
|
||||
### 7.2 Testing Strategy
|
||||
|
||||
Every phase must pass these test categories before exit:
|
||||
|
||||
| Category | Scope | Where it lives |
|
||||
|---|---|---|
|
||||
| **Unit (engine)** | Pure-Python; no Odoo DB. Algorithm correctness with fixtures | `tests/test_<feature>_engine.py` |
|
||||
| **Integration (Odoo TestCase)** | Full Odoo DB; install + create data + exercise workflow + assert state | `tests/test_<feature>_integration.py` |
|
||||
| **Migration round-trip** | Install Enterprise, create Enterprise-only data, install fusion, run wizard, uninstall Enterprise, assert data integrity | `tests/test_migration.py` |
|
||||
| **Tour (JS)** | End-to-end widget UI smoke | `tests/tours/<feature>_tour.js` |
|
||||
| **Performance** | Phase 2 reports especially; assert P95 latency at 1k/10k/100k rows | `tests/test_<feature>_performance.py` |
|
||||
| **Multi-matrix** | Single-company, multi-company, multi-currency, cash-basis on/off | parameterized within other tests |
|
||||
|
||||
CI runs all tests on every push. A nightly job runs migration tests against a fixture Enterprise DB.
|
||||
|
||||
### 7.3 Documentation Deliverables
|
||||
|
||||
Per sub-module:
|
||||
|
||||
- `CLAUDE.md` — module-specific context for Cursor/AI assistance
|
||||
- `UPGRADE_NOTES.md` — Odoo version porting log
|
||||
- `README.md` — operator-facing: install, configure, troubleshoot, common gotchas
|
||||
- One screencast or animated GIF per major user workflow, in `static/description/`
|
||||
- Per-feature feature flag documentation in `CLAUDE.md` if applicable
|
||||
|
||||
Workspace-root documentation:
|
||||
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md` — common Odoo 19 conventions (already substantial; carries forward)
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md` — meta-module overview pointing at sub-modules
|
||||
- `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/docs/superpowers/specs/` — design and plan docs (this doc and future ones)
|
||||
|
||||
### 7.4 Security
|
||||
|
||||
- Three groups carry forward from existing module: `group_fusion_accounting_user/manager/admin`. Move from current location to `fusion_accounting_core/security/security.xml` in Phase 0
|
||||
- Auto-assignments from Community accounting groups: `account.group_account_user` → fusion User; `account.group_account_manager` → fusion Admin (already in place)
|
||||
- Multi-company record rules on every fusion model with `company_id`. Add the missing rule on `fusion.accounting.session` in Phase 0
|
||||
- ACLs in `security/ir.model.access.csv` per sub-module, scoped to that sub-module's models only
|
||||
- Approve/reject endpoints continue to use `auth='user'` with imperative `has_group()` check inside the handler (Odoo has no built-in `auth='manager'`)
|
||||
|
||||
### 7.5 Performance Considerations (Phase 2 in particular)
|
||||
|
||||
Odoo Enterprise reports have known performance issues on large databases. The Phase 2 design doc must lock in:
|
||||
|
||||
- Denormalized read paths for trial balance and general ledger (materialized aggregations refreshed on `account.move` post)
|
||||
- Lazy-load line detail (drill-down fetches separately, not all at once)
|
||||
- Cache report runs per `(company_id, period, filter_hash)` with invalidation on `account.move.line` write/post/cancel
|
||||
- Parallel computation across companies in multi-company reports
|
||||
- SQL query review (no Python aggregation of large result sets)
|
||||
|
||||
### 7.6 Multi-Company, Multi-Currency, Analytic
|
||||
|
||||
Not a separate phase. Woven into every phase's exit criteria:
|
||||
|
||||
- Every fusion model with company-scoped data has `company_id` field and a multi-company record rule
|
||||
- Every monetary field pairs with `currency_id`
|
||||
- `analytic_mixin` (currently in `account_accountant/models/analytic_mixin.py`): declared in `fusion_accounting_core` via shared-field-ownership pattern so analytic tags survive Enterprise uninstall
|
||||
|
||||
### 7.7 Localization
|
||||
|
||||
Canadian HST is built into the existing AI module (`fusion_accounting_ai/services/prompts/domain_prompts.py`) and carries forward. Other localizations are deferred:
|
||||
|
||||
- Each country-specific tax report becomes a `fusion.account.report` record in `fusion_accounting_reports/data/<country>_<report>.xml`
|
||||
- Country-specific chart of accounts: continue to use Odoo's `account.chart.template` mechanism (Community)
|
||||
- New countries are added on demand, per client engagement
|
||||
|
||||
### 7.8 Hosting and Deployment
|
||||
|
||||
Out of scope for this design doc; covered in workspace-root operational docs. fusion_accounting deploys to the existing Nexa Odoo infrastructure (per existing `fusion_accounting/CLAUDE.md`: `odoo-westin` for Westin Healthcare, equivalents for other clients). Deploy commands in CLAUDE.md carry forward.
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Criteria for This Roadmap
|
||||
|
||||
This roadmap is considered "done" (and ready for the first writing-plans session for Phase 0) when:
|
||||
|
||||
- The user has reviewed this document and signed off
|
||||
- No unresolved ambiguity remains in any of the locked decisions (sub-module topology, data preservation, phase order, architecture rules, upgrade workflow)
|
||||
- The empirical verification test (Section 3.6) is scheduled as part of Phase 0 and not deferred
|
||||
|
||||
The next session's deliverable will be the Phase 0 implementation plan (via the `writing-plans` skill), which will turn Section 4.2 into actionable, testable tasks.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions Deferred to Future Sessions
|
||||
|
||||
Items consciously left open here, to be resolved in their respective phase brainstorming sessions:
|
||||
|
||||
- Phase 1: exact UI deltas from Odoo's bank rec widget (colour palette, AI confidence badge placement, keyboard shortcuts)
|
||||
- Phase 2: report definition data format (XML mirroring Odoo vs. our own simpler format)
|
||||
- Phase 2: caching layer implementation (in-memory vs. Redis vs. PostgreSQL materialized views)
|
||||
- Phase 4: which non-Canadian tax jurisdictions to seed
|
||||
- Phase 5: SMS provider integration (Twilio? `mail.sms` Odoo built-in?)
|
||||
- Phase 6: depreciation methods to support beyond linear/declining (sum-of-years-digits, units-of-production)
|
||||
- Phase 7+: which satellites have actual client demand right now
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- Workspace root: `/Users/gurpreet/Github/Odoo-Modules/`
|
||||
- Current AI module: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/`
|
||||
- Current AI module conventions: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting/CLAUDE.md`
|
||||
- Workspace conventions: `/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md`
|
||||
- WIP code (not continued): `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/`
|
||||
- WIP audit report: `/Users/gurpreet/Github/Odoo-Modules/Work in Progress/fusion_accounting/AUDIT_REPORT.md`
|
||||
- Pinned Odoo source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/`
|
||||
- Plan file (this session): `/Users/gurpreet/.cursor/plans/fusion_accounting_takeover_roadmap_c851fdb4.plan.md`
|
||||
@@ -168,5 +168,26 @@
|
||||
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
|
||||
</record>
|
||||
|
||||
<!-- Cron Job: ADP Hold Expiry Reminders (2026-04).
|
||||
For each on-hold ADP case:
|
||||
- Sends monthly reminder to the CLIENT (authorizer excluded per
|
||||
2026-04 authorizer email policy). Cadence:
|
||||
fusion_claims.adp_hold_reminder_interval_days (default 30).
|
||||
- Sends ONE final warning to client + authorizer when funding
|
||||
expires within fusion_claims.adp_hold_final_warning_days_before_expiry
|
||||
(default 30 days before expiry).
|
||||
- Silently skips cases where the client has no email on file.
|
||||
Flags reset automatically when the case resumes from hold. -->
|
||||
<record id="ir_cron_adp_hold_expiry_reminders" model="ir.cron">
|
||||
<field name="name">Fusion Claims: ADP Hold Expiry Reminders</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_adp_hold_expiry_reminders()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="nextcall" eval="DateTime.now().replace(hour=9, minute=30, second=0)"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
|
||||
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||
.fc-landscape .text-center { text-align: center; }
|
||||
.fc-landscape .text-end { text-align: right; }
|
||||
@@ -26,7 +30,7 @@
|
||||
.fc-landscape .client-bg { background-color: #fff3e0; }
|
||||
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-landscape .note-row { font-style: italic; }
|
||||
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
|
||||
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||
.fc-landscape .totals-table { border: 1px solid #000; }
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#005a83'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
@@ -25,7 +29,7 @@
|
||||
.fc-report .client-bg { background-color: #fff3e0; }
|
||||
.fc-report .section-row { background-color: #f8f8f8; font-weight: bold; }
|
||||
.fc-report .note-row { font-style: italic; }
|
||||
.fc-report h4 { color: #005a83; margin: 0 0 15px 0; }
|
||||
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; }
|
||||
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
|
||||
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
||||
</style>
|
||||
|
||||
@@ -12,19 +12,22 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-contract { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
|
||||
.fc-contract h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
|
||||
.fc-contract h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
|
||||
.fc-contract h4 { color: #0066a1; margin: 0 0 10px 0; font-size: 13pt; }
|
||||
.fc-contract h1 { color: <t t-out="primary"/>; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
|
||||
.fc-contract h2 { color: <t t-out="primary"/>; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
|
||||
.fc-contract h4 { color: <t t-out="primary"/>; margin: 0 0 10px 0; font-size: 13pt; }
|
||||
.fc-contract p { margin: 2px 0; text-align: justify; }
|
||||
.fc-contract .intro { margin-bottom: 8px; font-size: 8pt; }
|
||||
.fc-contract ul { margin: 2px 0 2px 15px; padding: 0; }
|
||||
.fc-contract li { margin-bottom: 1px; }
|
||||
.fc-contract table { width: 100%; border-collapse: collapse; }
|
||||
.fc-contract table.bordered, .fc-contract table.bordered th, .fc-contract table.bordered td { border: 1px solid #000; }
|
||||
.fc-contract th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
|
||||
.fc-contract th { background-color: <t t-out="primary"/>; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
|
||||
.fc-contract td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
|
||||
.fc-contract .text-center { text-align: center; }
|
||||
.fc-contract .text-end { text-align: right; }
|
||||
@@ -48,7 +51,7 @@
|
||||
.fc-contract .sig-row { display: table; width: 100%; margin-bottom: 20px; }
|
||||
.fc-contract .sig-col { display: table-cell; width: 48%; vertical-align: top; }
|
||||
.fc-contract .sig-spacer { display: table-cell; width: 4%; }
|
||||
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: #0066a1; margin-bottom: 8px; border-bottom: 2px solid #0066a1; padding-bottom: 3px; }
|
||||
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: <t t-out="primary"/>; margin-bottom: 8px; border-bottom: 2px solid <t t-out="primary"/>; padding-bottom: 3px; }
|
||||
.fc-contract .sig-field { margin-bottom: 12px; }
|
||||
.fc-contract .sig-line { border-bottom: 1px solid #000; min-height: 25px; }
|
||||
.fc-contract .sig-label { font-size: 7pt; color: #666; margin-top: 2px; }
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
<t t-set="is_deduction" t-value="doc.x_fc_adp_application_status == 'approved_deduction'"/>
|
||||
<t t-set="lines" t-value="doc.order_line.filtered(lambda l: l.product_id and l.display_type not in ('line_section', 'line_note'))"/>
|
||||
<t t-set="has_deduction" t-value="any(l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' for l in lines)"/>
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-ai h2 { color: #0066a1; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
|
||||
.fc-ai h2 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
|
||||
.fc-ai table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-ai table.bordered, .fc-ai table.bordered th, .fc-ai table.bordered td { border: 1px solid #000; }
|
||||
.fc-ai th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-ai th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-ai td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-ai .text-center { text-align: center; }
|
||||
.fc-ai .text-end { text-align: right; }
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="company" t-value="doc.company_id"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-waiver { font-family: Arial, sans-serif; font-size: 10pt; line-height: 1.5; }
|
||||
.fc-waiver h1 { color: #0066a1; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
|
||||
.fc-waiver h1 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
|
||||
.fc-waiver h2 { color: #333; font-size: 11pt; margin: 15px 0 8px 0; }
|
||||
.fc-waiver p { margin: 8px 0; text-align: justify; }
|
||||
.fc-waiver .intro { margin-bottom: 15px; font-style: italic; }
|
||||
|
||||
@@ -17,20 +17,24 @@
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<!-- Get sale order for MOD fields -->
|
||||
<t t-set="so" t-value="doc.x_fc_source_sale_order_id"/>
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-mod-inv { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-mod-inv table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-mod-inv table.bordered, .fc-mod-inv table.bordered th, .fc-mod-inv table.bordered td { border: 1px solid #000; }
|
||||
.fc-mod-inv th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod-inv th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod-inv td { padding: 6px 8px; vertical-align: top; }
|
||||
.fc-mod-inv .text-center { text-align: center; }
|
||||
.fc-mod-inv .text-end { text-align: right; }
|
||||
.fc-mod-inv .text-start { text-align: left; }
|
||||
.fc-mod-inv .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-mod-inv .note-row { font-style: italic; color: #555; font-size: 9pt; }
|
||||
.fc-mod-inv h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod-inv .req-box { border: 2px solid #1a5276; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
|
||||
.fc-mod-inv h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod-inv .req-box { border: 2px solid <t t-out="primary"/>; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
|
||||
</style>
|
||||
|
||||
<div class="fc-mod-inv">
|
||||
|
||||
@@ -9,21 +9,25 @@
|
||||
<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)"/>
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-mod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-mod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-mod table.bordered, .fc-mod table.bordered th, .fc-mod table.bordered td { border: 1px solid #000; }
|
||||
.fc-mod th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-mod td { padding: 6px 8px; vertical-align: top; }
|
||||
.fc-mod .text-center { text-align: center; }
|
||||
.fc-mod .text-end { text-align: right; }
|
||||
.fc-mod .text-start { text-align: left; }
|
||||
.fc-mod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-mod h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-mod .info-header { background-color: #eaf2f8; color: #333; }
|
||||
.fc-mod .mod-accent { color: #1a5276; font-weight: bold; }
|
||||
.fc-mod .highlight-box { border: 2px solid #1a5276; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
|
||||
.fc-mod .mod-accent { color: <t t-out="primary"/>; font-weight: bold; }
|
||||
.fc-mod .highlight-box { border: 2px solid <t t-out="primary"/>; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
|
||||
</style>
|
||||
|
||||
<div class="fc-mod">
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
|
||||
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-pod .text-center { text-align: center; }
|
||||
.fc-pod .text-end { text-align: right; }
|
||||
@@ -25,7 +29,7 @@
|
||||
.fc-pod .adp-bg { background-color: #e3f2fd; }
|
||||
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-pod .note-row { font-style: italic; }
|
||||
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
|
||||
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
|
||||
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }
|
||||
|
||||
@@ -11,19 +11,23 @@
|
||||
<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)"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
|
||||
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-pod .text-center { text-align: center; }
|
||||
.fc-pod .text-end { text-align: right; }
|
||||
.fc-pod .text-start { text-align: left; }
|
||||
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-pod .note-row { font-style: italic; }
|
||||
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
|
||||
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
|
||||
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }
|
||||
|
||||
@@ -11,19 +11,25 @@
|
||||
<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)"/>
|
||||
|
||||
<!-- Pickup uses the company's secondary brand colour (green by convention,
|
||||
distinct from the delivery report's primary). Falls back to legacy
|
||||
green if the company has not set a secondary colour. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#2e7d32'"/>
|
||||
|
||||
<style>
|
||||
.fc-pop { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-pop table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-pop table.bordered, .fc-pop table.bordered th, .fc-pop table.bordered td { border: 1px solid #000; }
|
||||
.fc-pop th { background-color: #2e7d32; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pop th { background-color: <t t-out="secondary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
|
||||
.fc-pop td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||
.fc-pop .text-center { text-align: center; }
|
||||
.fc-pop .text-end { text-align: right; }
|
||||
.fc-pop .text-start { text-align: left; }
|
||||
.fc-pop .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-pop .note-row { font-style: italic; }
|
||||
.fc-pop h2 { color: #2e7d32; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pop h2 { color: <t t-out="secondary"/>; margin: 8px 0; font-size: 16pt; }
|
||||
.fc-pop .info-table td { padding: 6px 10px; font-size: 10pt; }
|
||||
.fc-pop .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
|
||||
.fc-pop .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<!--
|
||||
Colour convention used across fusion_claims reports (2026-04):
|
||||
Each report should set `primary` and `secondary` near the top of
|
||||
its template body, drawing from the company's brand colours
|
||||
configured via the document-layout wizard:
|
||||
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
Then reference `<t t-out="primary"/>` inside the <style> block and
|
||||
inline `style="..."` attributes. Fallbacks preserve legacy rendering
|
||||
on databases that have never set a colour.
|
||||
-->
|
||||
|
||||
<!-- Shared Report Header Template -->
|
||||
<template id="report_header_fusion_claims">
|
||||
<div class="fc-header">
|
||||
@@ -117,20 +132,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Report Styles -->
|
||||
<!-- Report Styles (callers should set `primary` before t-call). -->
|
||||
<template id="report_styles_fusion_claims">
|
||||
<t t-set="primary" t-value="primary or ((company.primary_color if company else False) or '#0077b6')"/>
|
||||
<style>
|
||||
.fc-header { margin-bottom: 20px; }
|
||||
.fc-footer { margin-top: 20px; }
|
||||
.fc-table { width: 100%; border-collapse: collapse; }
|
||||
.fc-table th { background-color: #0077b6; color: white; padding: 8px; text-align: left; }
|
||||
.fc-table th { background-color: <t t-out="primary"/>; color: white; padding: 8px; text-align: left; }
|
||||
.fc-table td { padding: 6px; border-bottom: 1px solid #ddd; }
|
||||
.fc-table .section-header { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-table .text-end { text-align: right; }
|
||||
.fc-totals { margin-top: 20px; }
|
||||
.fc-totals table { width: 300px; float: right; }
|
||||
.fc-totals td { padding: 4px 8px; }
|
||||
.fc-totals .total-row { font-weight: bold; background-color: #0077b6; color: white; }
|
||||
.fc-totals .total-row { font-weight: bold; background-color: <t t-out="primary"/>; color: white; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
|
||||
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
|
||||
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||
.fc-landscape .text-center { text-align: center; }
|
||||
.fc-landscape .text-end { text-align: right; }
|
||||
@@ -26,7 +30,7 @@
|
||||
.fc-landscape .client-bg { background-color: #fff3e0; }
|
||||
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-landscape .note-row { font-style: italic; }
|
||||
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
|
||||
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
|
||||
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||
.fc-landscape .totals-table { border: 1px solid #000; }
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
<t t-call="web.external_layout">
|
||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
|
||||
|
||||
<!-- Brand colours from the company's document-layout config. -->
|
||||
<t t-set="_co" t-value="doc.company_id or env.company"/>
|
||||
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
|
||||
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
|
||||
|
||||
<style>
|
||||
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||
.fc-report table.bordered, .fc-report table.bordered th, .fc-report table.bordered td { border: 1px solid #000; }
|
||||
.fc-report th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-report th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
|
||||
.fc-report td { padding: 6px 8px; vertical-align: top; }
|
||||
.fc-report .text-center { text-align: center; }
|
||||
.fc-report .text-end { text-align: right; }
|
||||
@@ -25,7 +29,7 @@
|
||||
.fc-report .client-bg { background-color: #fff3e0; }
|
||||
.fc-report .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||
.fc-report .note-row { font-style: italic; }
|
||||
.fc-report h4 { color: #0066a1; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
|
||||
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
|
||||
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
||||
.fc-report .info-header { background-color: #f5f5f5; color: #333; }
|
||||
|
||||
@@ -1385,12 +1385,12 @@
|
||||
confirm="Reopen this cancelled application at the Quotation stage?"
|
||||
help="Return a cancelled application to Quotation"/>
|
||||
|
||||
<button name="action_adp_reopen_expired" type="object"
|
||||
string="Reopen" class="btn-info"
|
||||
icon="fa-refresh"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'expired'"
|
||||
confirm="Reopen this expired application at the Quotation stage?"
|
||||
help="Return an expired application to Quotation"/>
|
||||
<button name="action_adp_duplicate_for_reassessment" type="object"
|
||||
string="Create Reassessment Order" class="btn-primary"
|
||||
icon="fa-copy"
|
||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('expired', 'cancelled')"
|
||||
confirm="Create a new sale order for reassessment? The old order stays as a historical record. The authorizer will need to complete a new assessment before resubmission."
|
||||
help="Create a new order linked to this one so the authorizer can reassess the client's needs"/>
|
||||
|
||||
<button name="action_adp_resubmit_from_denied" type="object"
|
||||
string="Resubmit" class="btn-primary"
|
||||
@@ -1604,11 +1604,11 @@
|
||||
icon="fa-refresh"
|
||||
invisible="x_fc_adp_application_status != 'cancelled'"
|
||||
confirm="Reopen this cancelled application at the Quotation stage?"/>
|
||||
<button name="action_adp_reopen_expired" type="object"
|
||||
string="Reopen" class="btn-info btn-sm me-1"
|
||||
icon="fa-refresh"
|
||||
invisible="x_fc_adp_application_status != 'expired'"
|
||||
confirm="Reopen this expired application at the Quotation stage?"/>
|
||||
<button name="action_adp_duplicate_for_reassessment" type="object"
|
||||
string="Create Reassessment Order" class="btn-primary btn-sm me-1"
|
||||
icon="fa-copy"
|
||||
invisible="x_fc_adp_application_status not in ('expired', 'cancelled')"
|
||||
confirm="Create a new sale order for reassessment? The authorizer will need to complete a new assessment before resubmission."/>
|
||||
<button name="action_adp_resubmit_from_denied" type="object"
|
||||
string="Resubmit" class="btn-primary btn-sm me-1"
|
||||
icon="fa-repeat"
|
||||
@@ -2635,10 +2635,134 @@
|
||||
context="{'group_by': 'x_fc_adp_application_status'}"/>
|
||||
<filter string="Client Type" name="group_client_type"
|
||||
context="{'group_by': 'x_fc_client_type'}"/>
|
||||
<filter string="Authorizer" name="group_authorizer"
|
||||
<filter string="Authorizer" name="group_authorizer"
|
||||
context="{'group_by': 'x_fc_authorizer_id'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SALE ORDER FORM: WSIB / Insurance / MDC / Hardship Case Details -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_order_form_fusion_claims_funder_workflows" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.fusion.claims.funder.workflows</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="priority">48</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='sale_header']" position="after">
|
||||
<field name="x_fc_show_wsib_fields" invisible="1"/>
|
||||
<field name="x_fc_show_insurance_fields" invisible="1"/>
|
||||
<field name="x_fc_show_mdc_fields" invisible="1"/>
|
||||
<field name="x_fc_show_hardship_fields" invisible="1"/>
|
||||
<field name="x_fc_is_wsib_sale" invisible="1"/>
|
||||
<field name="x_fc_is_insurance_sale" invisible="1"/>
|
||||
<field name="x_fc_is_mdc_sale" invisible="1"/>
|
||||
<field name="x_fc_is_hardship_sale" invisible="1"/>
|
||||
|
||||
<!-- ================== WSIB ================== -->
|
||||
<group name="wsib_case_details" string="WSIB Case"
|
||||
invisible="not x_fc_show_wsib_fields">
|
||||
<group>
|
||||
<field name="x_fc_wsib_status" string="Status"
|
||||
required="x_fc_sale_type == 'wsib'"/>
|
||||
<field name="x_fc_wsib_claim_number"/>
|
||||
<field name="x_fc_wsib_adjudicator_name"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_wsib_form_7_date"/>
|
||||
<field name="x_fc_wsib_approval_date"
|
||||
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
|
||||
<field name="x_fc_wsib_approval_letter"
|
||||
filename="x_fc_wsib_approval_letter_filename"
|
||||
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
|
||||
<field name="x_fc_wsib_approval_letter_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- ================== INSURANCE ================== -->
|
||||
<group name="insurance_case_details" string="Insurance Case"
|
||||
invisible="not x_fc_show_insurance_fields">
|
||||
<group>
|
||||
<field name="x_fc_insurance_status" string="Status"
|
||||
required="x_fc_sale_type == 'insurance'"/>
|
||||
<field name="x_fc_insurance_submission_mode"/>
|
||||
<field name="x_fc_insurance_company_id"/>
|
||||
<field name="x_fc_insurance_letter_source"/>
|
||||
<field name="x_fc_insurance_home_assessment_required"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_insurance_policy_number"/>
|
||||
<field name="x_fc_insurance_claim_number"/>
|
||||
<field name="x_fc_insurance_pre_auth_amount"
|
||||
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
|
||||
<field name="x_fc_insurance_pre_auth_expiry"
|
||||
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
|
||||
<field name="x_fc_insurance_approval_letter"
|
||||
filename="x_fc_insurance_approval_letter_filename"
|
||||
invisible="x_fc_insurance_status in ('quotation', 'home_assessment_scheduled', 'home_assessment_completed', 'documents_ready', 'submitted_by_client', 'pre_auth_submitted')"/>
|
||||
<field name="x_fc_insurance_approval_letter_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- ================== MDC ================== -->
|
||||
<group name="mdc_case_details" string="Muscular Dystrophy Case"
|
||||
invisible="not x_fc_show_mdc_fields">
|
||||
<group>
|
||||
<field name="x_fc_mdc_status" string="Status"
|
||||
required="x_fc_sale_type == 'muscular_dystrophy'"/>
|
||||
<field name="x_fc_mdc_client_id_number"/>
|
||||
<field name="x_fc_mdc_enrollment_verified"/>
|
||||
<field name="x_fc_mdc_enrollment_verified_date"
|
||||
invisible="not x_fc_mdc_enrollment_verified"/>
|
||||
<field name="x_fc_mdc_letter_source"/>
|
||||
<field name="x_fc_mdc_submitted_by"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_mdc_po_number"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_po_date"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_po_amount"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_payment_due_date" readonly="1"
|
||||
invisible="not x_fc_mdc_po_date"/>
|
||||
<field name="x_fc_mdc_po_document"
|
||||
filename="x_fc_mdc_po_document_filename"
|
||||
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
|
||||
<field name="x_fc_mdc_po_document_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- ================== HARDSHIP ================== -->
|
||||
<group name="hardship_case_details" string="Hardship Funding Case"
|
||||
invisible="not x_fc_show_hardship_fields">
|
||||
<group>
|
||||
<field name="x_fc_hardship_status" string="Status"
|
||||
required="x_fc_sale_type == 'hardship'"/>
|
||||
<field name="x_fc_hardship_funder_id"/>
|
||||
<field name="x_fc_hardship_submitted_by"/>
|
||||
<field name="x_fc_hardship_pre_assessment_source"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_hardship_interview_date"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf')"/>
|
||||
<field name="x_fc_hardship_approval_date"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_received_via"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_amount"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_client_portion"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_letter"
|
||||
filename="x_fc_hardship_approval_letter_filename"
|
||||
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
|
||||
<field name="x_fc_hardship_approval_letter_filename" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
32
fusion_plating/fusion_plating/__init__.py
Normal file
32
fusion_plating/fusion_plating/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
"""Auto-detect a sensible default timezone on first install.
|
||||
|
||||
Sets ``res.company.x_fc_default_tz`` to the admin user's timezone
|
||||
(Odoo populates that from the browser on first login), falling back
|
||||
to the host server's timezone, then to ``America/Toronto`` as a
|
||||
last resort. Only writes when the field is still empty so re-installs
|
||||
never clobber a user's choice.
|
||||
"""
|
||||
from .models.fp_tz import detect_default_tz
|
||||
|
||||
detected = detect_default_tz(env)
|
||||
for company in env['res.company'].sudo().search([]):
|
||||
if not company.x_fc_default_tz:
|
||||
company.x_fc_default_tz = detected
|
||||
_logger.info(
|
||||
'Fusion Plating: set default timezone for company %s -> %s',
|
||||
company.name, detected,
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.3.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -90,9 +90,14 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_facility_views.xml',
|
||||
'views/fp_bath_views.xml',
|
||||
'views/fp_process_node_views.xml',
|
||||
'views/fp_rack_views.xml',
|
||||
'views/fp_bath_replenishment_views.xml',
|
||||
'views/fp_operator_certification_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_menu.xml',
|
||||
'data/fp_recipe_enp_alum_basic.xml',
|
||||
],
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating/static/src/scss/fusion_plating.scss',
|
||||
@@ -12,5 +12,10 @@ from . import fp_bath
|
||||
from . import fp_bath_log
|
||||
from . import fp_bath_log_line
|
||||
from . import fp_bath_parameter
|
||||
from . import fp_bath_replenishment_rule
|
||||
from . import fp_process_node
|
||||
from . import fp_rack
|
||||
from . import fp_operator_certification
|
||||
from . import fp_tz
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
@@ -112,3 +112,47 @@ class FpBathLogLine(models.Model):
|
||||
rec.status = 'warning'
|
||||
else:
|
||||
rec.status = 'ok'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1.2 — Auto-suggest replenishment on every log line
|
||||
# ------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super().create(vals_list)
|
||||
lines._spawn_replenishment_suggestions()
|
||||
return lines
|
||||
|
||||
def _spawn_replenishment_suggestions(self):
|
||||
"""For every out-of-spec reading, run the matching replenishment
|
||||
rule and create a pending suggestion the operator can apply."""
|
||||
Rule = self.env['fusion.plating.bath.replenishment.rule']
|
||||
Suggestion = self.env['fusion.plating.bath.replenishment.suggestion']
|
||||
for line in self:
|
||||
if not line.parameter_id or not line.log_id.bath_id:
|
||||
continue
|
||||
bath = line.log_id.bath_id
|
||||
rules = Rule._find_rules(bath, line.parameter_id.id)
|
||||
for rule in rules:
|
||||
dose = rule._compute_dose(
|
||||
line.value, line.target_min, line.target_max, bath.volume,
|
||||
)
|
||||
if dose <= 0:
|
||||
continue
|
||||
Suggestion.create({
|
||||
'bath_id': bath.id,
|
||||
'log_line_id': line.id,
|
||||
'rule_id': rule.id,
|
||||
'parameter_id': line.parameter_id.id,
|
||||
'current_value': line.value,
|
||||
'target_min': line.target_min,
|
||||
'target_max': line.target_max,
|
||||
'product_name': rule.product_name,
|
||||
'dose_amount': dose,
|
||||
'dose_uom': rule.dose_uom,
|
||||
'state': 'pending',
|
||||
})
|
||||
bath.message_post(
|
||||
body=f'Replenishment suggested: add {dose} {rule.dose_uom} '
|
||||
f'of {rule.product_name} ({line.parameter_id.name} '
|
||||
f'reading: {line.value})',
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
# -*- 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 FpBathReplenishmentRule(models.Model):
|
||||
"""Linear replenishment rule: when a chemistry reading drifts outside
|
||||
target, calculate how much replenisher to add.
|
||||
|
||||
The formula is deliberately simple:
|
||||
dose = deficit × bath.volume × dose_rate
|
||||
|
||||
where deficit = (target_min − value) for below_min rules
|
||||
or = (value − target_max) for above_max rules.
|
||||
|
||||
Shops wanting non-linear or piecewise rules can extend this model.
|
||||
"""
|
||||
_name = 'fusion.plating.bath.replenishment.rule'
|
||||
_description = 'Fusion Plating — Replenishment Rule'
|
||||
_order = 'process_type_id, parameter_id'
|
||||
|
||||
name = fields.Char(string='Rule Name', required=True)
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type', string='Process Type',
|
||||
help='If set, this rule applies to every bath running this process. '
|
||||
'Leave blank and set bath_id for a bath-specific rule.',
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Specific Bath',
|
||||
help='Narrow the rule to a single bath (overrides process-level rule).',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter', required=True,
|
||||
)
|
||||
trigger = fields.Selection(
|
||||
[('below_min', 'Reading Below Target Min'),
|
||||
('above_max', 'Reading Above Target Max')],
|
||||
string='Trigger', required=True, default='below_min',
|
||||
)
|
||||
product_name = fields.Char(
|
||||
string='Replenisher Name', required=True,
|
||||
help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Product (Inventory)',
|
||||
help='Optional link to an inventory product for consumption tracking.',
|
||||
)
|
||||
dose_rate = fields.Float(
|
||||
string='Dose Rate', required=True, digits=(12, 4),
|
||||
help='Amount of replenisher per unit of parameter deficit per gallon '
|
||||
'of bath volume. E.g. 0.5 means "add 0.5 mL per (g/L deficit) per gallon".',
|
||||
)
|
||||
dose_uom = fields.Selection(
|
||||
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
|
||||
string='Dose UoM', required=True, default='ml',
|
||||
)
|
||||
min_dose = fields.Float(
|
||||
string='Minimum Dose', default=0.0,
|
||||
help='Do not suggest doses below this (useful to avoid noise).',
|
||||
)
|
||||
max_dose = fields.Float(
|
||||
string='Safety Cap', default=0.0,
|
||||
help='Cap the suggested dose. 0 = no cap.',
|
||||
)
|
||||
notes = fields.Text(string='Operator Notes')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.model
|
||||
def _find_rules(self, bath, parameter_id):
|
||||
"""Return rules applicable to this (bath, parameter). Bath-specific
|
||||
rules take precedence over process-level ones.
|
||||
"""
|
||||
bath_rule = self.search([
|
||||
('bath_id', '=', bath.id),
|
||||
('parameter_id', '=', parameter_id),
|
||||
('active', '=', True),
|
||||
])
|
||||
if bath_rule:
|
||||
return bath_rule
|
||||
return self.search([
|
||||
('bath_id', '=', False),
|
||||
('process_type_id', '=', bath.process_type_id.id),
|
||||
('parameter_id', '=', parameter_id),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
def _compute_dose(self, value, target_min, target_max, bath_volume):
|
||||
"""Return a dose amount for this rule given the reading context.
|
||||
Returns 0.0 if the trigger doesn't apply.
|
||||
"""
|
||||
self.ensure_one()
|
||||
deficit = 0.0
|
||||
if self.trigger == 'below_min' and target_min and value < target_min:
|
||||
deficit = target_min - value
|
||||
elif self.trigger == 'above_max' and target_max and value > target_max:
|
||||
deficit = value - target_max
|
||||
if deficit <= 0:
|
||||
return 0.0
|
||||
dose = deficit * (bath_volume or 1.0) * self.dose_rate
|
||||
if self.min_dose and dose < self.min_dose:
|
||||
return 0.0
|
||||
if self.max_dose and dose > self.max_dose:
|
||||
dose = self.max_dose
|
||||
return round(dose, 3)
|
||||
|
||||
|
||||
class FpBathReplenishmentSuggestion(models.Model):
|
||||
"""One suggestion generated from a bath-log reading. Operators mark
|
||||
them applied or dismissed once the dose has been added."""
|
||||
_name = 'fusion.plating.bath.replenishment.suggestion'
|
||||
_description = 'Fusion Plating — Replenishment Suggestion'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath', required=True, ondelete='cascade',
|
||||
)
|
||||
log_line_id = fields.Many2one(
|
||||
'fusion.plating.bath.log.line', string='Triggering Reading',
|
||||
ondelete='cascade',
|
||||
)
|
||||
rule_id = fields.Many2one(
|
||||
'fusion.plating.bath.replenishment.rule', string='Rule',
|
||||
ondelete='set null',
|
||||
)
|
||||
parameter_id = fields.Many2one(
|
||||
'fusion.plating.bath.parameter', string='Parameter', required=True,
|
||||
)
|
||||
current_value = fields.Float(string='Current Reading', digits=(12, 4))
|
||||
target_min = fields.Float(string='Target Min', digits=(12, 4))
|
||||
target_max = fields.Float(string='Target Max', digits=(12, 4))
|
||||
product_name = fields.Char(string='Replenisher', required=True)
|
||||
dose_amount = fields.Float(string='Suggested Dose', digits=(12, 3))
|
||||
dose_uom = fields.Selection(
|
||||
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
|
||||
string='UoM', required=True, default='ml',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('pending', 'Pending'), ('applied', 'Applied'), ('dismissed', 'Dismissed')],
|
||||
default='pending', tracking=True,
|
||||
)
|
||||
applied_at = fields.Datetime(readonly=True)
|
||||
applied_by_id = fields.Many2one('res.users', readonly=True)
|
||||
charged_to_mo_ref = fields.Char(
|
||||
string='Charged to MO',
|
||||
help='Manufacturing order this replenishment was charged against '
|
||||
'(for job costing). Blank = unassigned.',
|
||||
)
|
||||
|
||||
def action_apply(self):
|
||||
"""Mark applied + log to bath chatter. A follow-up JobConsumption
|
||||
record can be created by `action_apply_and_charge()` to attribute
|
||||
cost to a specific MO.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'applied',
|
||||
'applied_at': fields.Datetime.now(),
|
||||
'applied_by_id': self.env.user.id,
|
||||
})
|
||||
rec.bath_id.message_post(
|
||||
body=f'Replenishment applied: {rec.dose_amount} {rec.dose_uom} '
|
||||
f'of {rec.product_name} (parameter: {rec.parameter_id.name})'
|
||||
)
|
||||
|
||||
def action_dismiss(self):
|
||||
self.write({'state': 'dismissed'})
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- 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 FpOperatorCertification(models.Model):
|
||||
"""A signed-off training record that certifies an operator on a
|
||||
specific process type.
|
||||
|
||||
Used to gate shop-floor work orders: an operator cannot start a
|
||||
plating WO unless they hold a current (non-expired) certification
|
||||
for that process.
|
||||
"""
|
||||
_name = 'fp.operator.certification'
|
||||
_description = 'Fusion Plating — Operator Certification'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'employee_id, process_type_id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Certification Ref',
|
||||
compute='_compute_name', store=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Operator', required=True,
|
||||
ondelete='cascade', tracking=True,
|
||||
)
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type', string='Process Type',
|
||||
required=True, ondelete='restrict', tracking=True,
|
||||
)
|
||||
issued_date = fields.Date(
|
||||
string='Issued', default=fields.Date.today, required=True,
|
||||
)
|
||||
expires_date = fields.Date(
|
||||
string='Expires',
|
||||
help='Blank = no expiry. Set a date for re-certification tracking.',
|
||||
)
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Certified By', default=lambda self: self.env.user,
|
||||
)
|
||||
training_record_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Training Record',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
revoked = fields.Boolean(string='Revoked', tracking=True)
|
||||
revoked_reason = fields.Text(string='Revoked Reason')
|
||||
state = fields.Selection(
|
||||
[('active', 'Active'),
|
||||
('expired', 'Expired'),
|
||||
('revoked', 'Revoked')],
|
||||
string='Status',
|
||||
compute='_compute_state', store=True, tracking=True,
|
||||
# NOT readonly=False — this is purely derived from revoked + expires_date
|
||||
# so the nightly recompute never fights with manual edits.
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'process_type_id')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
if rec.employee_id and rec.process_type_id:
|
||||
rec.name = f'{rec.employee_id.name} / {rec.process_type_id.name}'
|
||||
else:
|
||||
rec.name = ''
|
||||
|
||||
@api.depends('expires_date', 'revoked')
|
||||
def _compute_state(self):
|
||||
today = fields.Date.today()
|
||||
for rec in self:
|
||||
if rec.revoked:
|
||||
rec.state = 'revoked'
|
||||
elif rec.expires_date and rec.expires_date < today:
|
||||
rec.state = 'expired'
|
||||
else:
|
||||
rec.state = 'active'
|
||||
|
||||
@api.constrains('employee_id', 'process_type_id', 'revoked', 'expires_date')
|
||||
def _check_single_active(self):
|
||||
"""At most one active certification per (employee, process_type)."""
|
||||
today = fields.Date.today()
|
||||
for rec in self:
|
||||
if rec.revoked:
|
||||
continue
|
||||
if rec.expires_date and rec.expires_date < today:
|
||||
continue
|
||||
# This record is active — look for another active sibling
|
||||
dupes = self.search_count([
|
||||
('id', '!=', rec.id),
|
||||
('employee_id', '=', rec.employee_id.id),
|
||||
('process_type_id', '=', rec.process_type_id.id),
|
||||
('revoked', '=', False),
|
||||
'|', ('expires_date', '=', False),
|
||||
('expires_date', '>=', today),
|
||||
])
|
||||
if dupes:
|
||||
from odoo.exceptions import ValidationError
|
||||
raise ValidationError(_(
|
||||
'Operator %s already has an active certification for "%s". '
|
||||
'Revoke or expire the existing one before adding another.'
|
||||
) % (rec.employee_id.name, rec.process_type_id.name))
|
||||
|
||||
def action_revoke(self):
|
||||
for rec in self:
|
||||
rec.revoked = True
|
||||
rec.message_post(body=_('Certification revoked.'))
|
||||
|
||||
@api.model
|
||||
def has_active_cert(self, employee_id, process_type_id):
|
||||
"""Utility — True if this employee holds a current certification.
|
||||
|
||||
Checks revoked + expires_date directly instead of the computed
|
||||
`state` column, so even a certification that expired yesterday
|
||||
is caught immediately (no wait for nightly recompute).
|
||||
"""
|
||||
if not employee_id or not process_type_id:
|
||||
return False
|
||||
today = fields.Date.today()
|
||||
return bool(self.search_count([
|
||||
('employee_id', '=', employee_id),
|
||||
('process_type_id', '=', process_type_id),
|
||||
('revoked', '=', False),
|
||||
'|', ('expires_date', '=', False),
|
||||
('expires_date', '>=', today),
|
||||
]))
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
x_fc_certification_ids = fields.One2many(
|
||||
'fp.operator.certification', 'employee_id',
|
||||
string='Plating Certifications',
|
||||
)
|
||||
x_fc_certified_process_ids = fields.Many2many(
|
||||
'fusion.plating.process.type', compute='_compute_certified_processes',
|
||||
string='Certified Processes',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_certification_ids.state', 'x_fc_certification_ids.process_type_id')
|
||||
def _compute_certified_processes(self):
|
||||
for emp in self:
|
||||
active = emp.x_fc_certification_ids.filtered(lambda c: c.state == 'active')
|
||||
emp.x_fc_certified_process_ids = active.mapped('process_type_id')
|
||||
@@ -6,6 +6,8 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .fp_tz import fp_isoformat_utc
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""A node in the process recipe tree.
|
||||
@@ -312,9 +314,11 @@ class FpProcessNode(models.Model):
|
||||
'child_count': len(children),
|
||||
'opt_in_out': self.opt_in_out or 'disabled',
|
||||
'input_count': len(self.input_ids),
|
||||
'create_date': self.create_date.isoformat() if self.create_date else '',
|
||||
# ISO with explicit UTC marker so JS new Date() parses it
|
||||
# correctly and re-localises to the browser's timezone.
|
||||
'create_date': fp_isoformat_utc(self.create_date),
|
||||
'create_uid_name': self.create_uid.name if self.create_uid else '',
|
||||
'write_date': self.write_date.isoformat() if self.write_date else '',
|
||||
'write_date': fp_isoformat_utc(self.write_date),
|
||||
'write_uid_name': self.write_uid.name if self.write_uid else '',
|
||||
'children': children,
|
||||
}
|
||||
@@ -39,6 +39,20 @@ class FpProcessType(models.Model):
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
process_family = fields.Selection(
|
||||
[('pre_treatment', 'Pre-Treatment'),
|
||||
('plating', 'Plating'),
|
||||
('post_treatment', 'Post-Treatment'),
|
||||
('bake', 'Hydrogen Bake / Heat Treat'),
|
||||
('strip', 'Strip'),
|
||||
('passivation', 'Passivation'),
|
||||
('masking', 'Masking / De-masking'),
|
||||
('inspection', 'Inspection / QC')],
|
||||
string='Family', default='plating', required=True, tracking=True,
|
||||
help='High-level grouping used to filter baths and plan routings. '
|
||||
'Pre-treatments (alkaline clean, acid etch, zincate) should be '
|
||||
'tracked as full baths with their own chemistry logs.',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
117
fusion_plating/fusion_plating/models/fp_rack.py
Normal file
117
fusion_plating/fusion_plating/models/fp_rack.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- 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 FpRack(models.Model):
|
||||
"""Plating rack / barrel / fixture.
|
||||
|
||||
Racks carry parts through baths and accumulate nickel themselves over
|
||||
time. Once the rack's metal turnover (MTO) count exceeds the strip
|
||||
interval, the rack must be stripped before re-use to avoid bald spots
|
||||
on parts.
|
||||
"""
|
||||
_name = 'fusion.plating.rack'
|
||||
_description = 'Fusion Plating — Rack / Fixture'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'facility_id, rack_type, name'
|
||||
|
||||
name = fields.Char(string='Rack ID', required=True, tracking=True)
|
||||
rack_type = fields.Selection(
|
||||
[('rack', 'Rack'), ('barrel', 'Barrel'),
|
||||
('fixture', 'Fixture'), ('basket', 'Basket')],
|
||||
string='Type', required=True, default='rack',
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility', string='Facility', required=True, tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', related='facility_id.company_id', store=True, readonly=True,
|
||||
)
|
||||
capacity = fields.Integer(
|
||||
string='Capacity (parts)',
|
||||
help='Max parts per load. Used for batch planning.',
|
||||
)
|
||||
contact_points = fields.Integer(
|
||||
string='Contact Points',
|
||||
help='Number of clips/tips that touch parts. Wear points for re-stripping.',
|
||||
)
|
||||
|
||||
# --- Wear tracking ---
|
||||
mto_count = fields.Float(
|
||||
string='MTO (current)', default=0.0, tracking=True,
|
||||
help='Metal turnover accumulated since last strip.',
|
||||
)
|
||||
strip_interval_mto = fields.Float(
|
||||
string='Strip After (MTO)', default=3.0,
|
||||
help='When MTO crosses this value, rack needs stripping.',
|
||||
)
|
||||
last_stripped_date = fields.Datetime(string='Last Stripped', tracking=True)
|
||||
last_stripped_by_id = fields.Many2one(
|
||||
'res.users', string='Stripped By', tracking=True,
|
||||
)
|
||||
strips_count = fields.Integer(string='Total Strips', default=0, readonly=True)
|
||||
|
||||
state = fields.Selection(
|
||||
[('active', 'Active'),
|
||||
('needs_strip', 'Needs Strip'),
|
||||
('stripping', 'Stripping'),
|
||||
('retired', 'Retired')],
|
||||
string='Status', default='active', required=True, tracking=True,
|
||||
compute='_compute_state', store=True, readonly=False,
|
||||
)
|
||||
status_color = fields.Integer(compute='_compute_status_color')
|
||||
notes = fields.Html(string='Notes')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_rack_facility_name_uniq', 'unique(facility_id, name)',
|
||||
'Rack ID must be unique per facility.'),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computes
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends('mto_count', 'strip_interval_mto')
|
||||
def _compute_state(self):
|
||||
for rec in self:
|
||||
if rec.state in ('stripping', 'retired'):
|
||||
continue # Manually set — don't override
|
||||
if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto:
|
||||
rec.state = 'needs_strip'
|
||||
elif rec.state != 'active':
|
||||
rec.state = 'active'
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_status_color(self):
|
||||
mapping = {'active': 4, 'needs_strip': 3, 'stripping': 2, 'retired': 10}
|
||||
for rec in self:
|
||||
rec.status_color = mapping.get(rec.state, 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_start_strip(self):
|
||||
self.write({'state': 'stripping'})
|
||||
|
||||
def action_mark_stripped(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'active',
|
||||
'mto_count': 0.0,
|
||||
'last_stripped_date': fields.Datetime.now(),
|
||||
'last_stripped_by_id': self.env.user.id,
|
||||
'strips_count': rec.strips_count + 1,
|
||||
})
|
||||
rec.message_post(body=_('Rack stripped and returned to service.'))
|
||||
|
||||
def action_retire(self):
|
||||
self.write({'state': 'retired', 'active': False})
|
||||
|
||||
def _increment_mto(self, delta=1.0):
|
||||
"""Add `delta` to the rack's MTO count. Called by the WO finish hook."""
|
||||
for rec in self:
|
||||
rec.mto_count = (rec.mto_count or 0.0) + delta
|
||||
161
fusion_plating/fusion_plating/models/fp_tz.py
Normal file
161
fusion_plating/fusion_plating/models/fp_tz.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Timezone helpers for Fusion Plating.
|
||||
|
||||
The Postgres database stores all datetimes naive-UTC. Anything that is
|
||||
shown to a user — dashboards, PDFs, emails, OWL frontends — must be
|
||||
converted to a human's local timezone first.
|
||||
|
||||
Resolution order for "what timezone does this user see":
|
||||
1. The current user's `res.users.tz`
|
||||
2. The current company's `x_fc_default_tz` (Fusion Plating setting)
|
||||
3. UTC
|
||||
|
||||
Use ``fp_user_tz(env)`` to get the resolved pytz tzinfo, then either
|
||||
convert datetimes yourself or use the convenience helpers
|
||||
``fp_format`` / ``fp_isoformat_utc``.
|
||||
"""
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
def fp_user_tz(env):
|
||||
"""Return a pytz tzinfo for the current user (or company fallback)."""
|
||||
name = (
|
||||
(env.user.tz if env and env.user else None)
|
||||
or (env.company.x_fc_default_tz if env and env.company else None)
|
||||
or 'UTC'
|
||||
)
|
||||
try:
|
||||
return pytz.timezone(name)
|
||||
except Exception:
|
||||
return pytz.UTC
|
||||
|
||||
|
||||
def fp_to_user_tz(env, dt):
|
||||
"""Convert a naive UTC datetime to a tz-aware datetime in the user's tz.
|
||||
|
||||
Returns ``None`` if ``dt`` is falsy. Datetimes that already carry a
|
||||
tzinfo are converted in place; naive ones are assumed to be UTC
|
||||
(matching Odoo's storage convention).
|
||||
"""
|
||||
if not dt:
|
||||
return None
|
||||
tz = fp_user_tz(env)
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(tz)
|
||||
|
||||
|
||||
def fp_format(env, dt, fmt='%Y-%m-%d %H:%M'):
|
||||
"""Format a naive UTC datetime as a string in the user's tz.
|
||||
|
||||
Returns an empty string when ``dt`` is falsy so callers can use the
|
||||
result directly in dicts / templates without ``if`` guards.
|
||||
"""
|
||||
if not dt:
|
||||
return ''
|
||||
return fp_to_user_tz(env, dt).strftime(fmt)
|
||||
|
||||
|
||||
def fp_isoformat_utc(dt):
|
||||
"""Return an ISO-8601 string with an explicit UTC marker.
|
||||
|
||||
Naive datetimes from Odoo are assumed UTC. Adding the ``+00:00``
|
||||
suffix tells JavaScript ``new Date(...)`` to parse the string as
|
||||
UTC (without it, the browser interprets the string as *local*
|
||||
wall time and silently shifts it). Pair this with frontend code
|
||||
that calls ``.toLocaleString()`` on the resulting Date object so
|
||||
the user sees their own local time.
|
||||
"""
|
||||
if not dt:
|
||||
return ''
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def fp_time_ago(env, dt):
|
||||
"""Return a 'just now / 5m ago / 2h ago / 3d ago' string.
|
||||
|
||||
Both sides of the comparison are converted to UTC tz-aware first so
|
||||
that the delta is meaningful regardless of where Odoo or the user
|
||||
happens to be running.
|
||||
"""
|
||||
if not dt:
|
||||
return ''
|
||||
if dt.tzinfo is None:
|
||||
dt = pytz.UTC.localize(dt)
|
||||
from datetime import datetime
|
||||
now = datetime.now(pytz.UTC)
|
||||
delta = now - dt
|
||||
total_seconds = int(delta.total_seconds())
|
||||
if total_seconds < 0:
|
||||
total_seconds = 0
|
||||
if total_seconds < 60:
|
||||
return 'just now'
|
||||
minutes = total_seconds // 60
|
||||
if minutes < 60:
|
||||
return f'{minutes}m ago'
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f'{hours}h ago'
|
||||
days = hours // 24
|
||||
if days < 7:
|
||||
return f'{days}d ago'
|
||||
weeks = days // 7
|
||||
remaining_days = days % 7
|
||||
if remaining_days:
|
||||
return f'{weeks}w {remaining_days}d ago'
|
||||
return f'{weeks}w ago'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-detection used by the post_init_hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FALLBACK_TZ = 'America/Toronto'
|
||||
|
||||
|
||||
def detect_default_tz(env=None):
|
||||
"""Best guess at a sensible default tz when the module is installed.
|
||||
|
||||
Tries, in order:
|
||||
1. The admin user's ``tz`` (Odoo sets it from the browser on login).
|
||||
2. The current company's ``partner_id.tz``.
|
||||
3. The host server's IANA timezone (Linux typical).
|
||||
4. ``America/Toronto`` as the final fallback (this project is
|
||||
Canada-focused and that's the most likely correct guess).
|
||||
"""
|
||||
if env is not None:
|
||||
admin = env.ref('base.user_admin', raise_if_not_found=False)
|
||||
if admin and admin.tz:
|
||||
return admin.tz
|
||||
try:
|
||||
partner_tz = env.company.partner_id.tz
|
||||
except Exception:
|
||||
partner_tz = None
|
||||
if partner_tz:
|
||||
return partner_tz
|
||||
|
||||
# Server-side detection — works on most Linux hosts.
|
||||
try:
|
||||
from datetime import datetime
|
||||
local = datetime.now().astimezone()
|
||||
name = str(local.tzinfo)
|
||||
if name and name in pytz.all_timezones:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open('/etc/timezone', 'r') as fh:
|
||||
tz = fh.read().strip()
|
||||
if tz in pytz.all_timezones:
|
||||
return tz
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _FALLBACK_TZ
|
||||
85
fusion_plating/fusion_plating/models/res_company.py
Normal file
85
fusion_plating/fusion_plating/models/res_company.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
def _fp_tz_get(self):
|
||||
"""Same selection list Odoo uses on res.partner.tz."""
|
||||
import pytz
|
||||
return [(tz, tz) for tz in pytz.all_timezones]
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
# ----- Fusion Plating default timezone --------------------------------
|
||||
# Used as the fallback whenever a user's res.users.tz is empty (cron
|
||||
# jobs, batch emails, headless contexts). Auto-populated by the
|
||||
# post_init_hook in fusion_plating/__init__.py.
|
||||
x_fc_default_tz = fields.Selection(
|
||||
_fp_tz_get,
|
||||
string='Fusion Plating Timezone',
|
||||
default=lambda self: self.env.user.tz or 'America/Toronto',
|
||||
help='Timezone used for plating dashboards, reports, and emails when '
|
||||
'a user has no personal timezone set. Detected automatically on '
|
||||
'install; the admin can change it any time from '
|
||||
'Settings > Fusion Plating.',
|
||||
)
|
||||
|
||||
# ----- Facility footprint for this legal entity ----------------------
|
||||
x_fc_facility_ids = fields.One2many(
|
||||
'fusion.plating.facility',
|
||||
'company_id',
|
||||
string='Plating Facilities',
|
||||
)
|
||||
x_fc_facility_count = fields.Integer(
|
||||
string='# Facilities',
|
||||
compute='_compute_x_fc_facility_count',
|
||||
)
|
||||
x_fc_default_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Default Facility',
|
||||
help='Facility used when the context does not specify one (single-site shops).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_facility_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
|
||||
|
||||
# =====================================================================
|
||||
# CoC / Certificate report settings
|
||||
# =====================================================================
|
||||
x_fc_owner_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Certificate Owner (Default Signer)',
|
||||
help='Quality manager / owner whose signature appears on Certificates '
|
||||
'of Conformance by default. Signature is pulled from their linked '
|
||||
'HR Employee record.',
|
||||
)
|
||||
x_fc_coc_signature_override = fields.Binary(
|
||||
string='Signature Override Image',
|
||||
help='Optional. Upload a pre-scanned signature image to use on '
|
||||
'Certificates of Conformance. Overrides the Owner user\'s '
|
||||
'employee signature when set. Useful if the owner doesn\'t have '
|
||||
'an HR record or wants a different signature for plating certs.',
|
||||
)
|
||||
|
||||
# --- Accreditation logos shown in CoC header ---
|
||||
x_fc_nadcap_logo = fields.Binary(string='Nadcap Logo')
|
||||
x_fc_nadcap_active = fields.Boolean(
|
||||
string='Nadcap Accredited',
|
||||
help='Show the Nadcap logo on certificates.',
|
||||
)
|
||||
x_fc_as9100_logo = fields.Binary(string='AS9100 / ISO 9001 Logo')
|
||||
x_fc_as9100_active = fields.Boolean(
|
||||
string='AS9100 / ISO 9001 Certified',
|
||||
help='Show the AS9100 / ISO 9001 logo on certificates.',
|
||||
)
|
||||
x_fc_cgp_logo = fields.Binary(string='Controlled Goods Program Logo')
|
||||
x_fc_cgp_active = fields.Boolean(
|
||||
string='CGP Registered',
|
||||
help='Show the Controlled Goods Program logo on certificates.',
|
||||
)
|
||||
22
fusion_plating/fusion_plating/models/res_config_settings.py
Normal file
22
fusion_plating/fusion_plating/models/res_config_settings.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- 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 fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
"""Expose Fusion Plating company-level settings on the Settings page.
|
||||
|
||||
Today this only carries the default timezone, but it's the single
|
||||
place to add new shop-wide preferences (default facility, currency
|
||||
overrides, etc.).
|
||||
"""
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
x_fc_default_tz = fields.Selection(
|
||||
related='company_id.x_fc_default_tz',
|
||||
readonly=False,
|
||||
string='Fusion Plating Timezone',
|
||||
)
|
||||
@@ -32,3 +32,15 @@ access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_proc
|
||||
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,group_fusion_plating_operator,1,1,0,0
|
||||
access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,group_fusion_plating_manager,1,1,1,1
|
||||
|
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,151 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ===== Rule List ===== -->
|
||||
<record id="view_fp_replenishment_rule_list" model="ir.ui.view">
|
||||
<field name="name">fp.replenishment.rule.list</field>
|
||||
<field name="model">fusion.plating.bath.replenishment.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="process_type_id"/>
|
||||
<field name="bath_id"/>
|
||||
<field name="parameter_id"/>
|
||||
<field name="trigger"/>
|
||||
<field name="product_name"/>
|
||||
<field name="dose_rate"/>
|
||||
<field name="dose_uom"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Rule Form ===== -->
|
||||
<record id="view_fp_replenishment_rule_form" model="ir.ui.view">
|
||||
<field name="name">fp.replenishment.rule.form</field>
|
||||
<field name="model">fusion.plating.bath.replenishment.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Scope">
|
||||
<field name="process_type_id"/>
|
||||
<field name="bath_id"/>
|
||||
<field name="parameter_id"/>
|
||||
<field name="trigger"/>
|
||||
</group>
|
||||
<group string="Dose">
|
||||
<field name="product_name"/>
|
||||
<field name="product_id"/>
|
||||
<field name="dose_rate"/>
|
||||
<field name="dose_uom"/>
|
||||
<field name="min_dose"/>
|
||||
<field name="max_dose"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_replenishment_rule" model="ir.actions.act_window">
|
||||
<field name="name">Replenishment Rules</field>
|
||||
<field name="res_model">fusion.plating.bath.replenishment.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Suggestion List ===== -->
|
||||
<record id="view_fp_replenishment_suggestion_list" model="ir.ui.view">
|
||||
<field name="name">fp.replenishment.suggestion.list</field>
|
||||
<field name="model">fusion.plating.bath.replenishment.suggestion</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-info="state == 'pending'"
|
||||
decoration-muted="state in ('applied','dismissed')"
|
||||
default_order="create_date desc">
|
||||
<field name="create_date" optional="show"/>
|
||||
<field name="bath_id"/>
|
||||
<field name="parameter_id"/>
|
||||
<field name="current_value"/>
|
||||
<field name="target_min"/>
|
||||
<field name="target_max"/>
|
||||
<field name="product_name"/>
|
||||
<field name="dose_amount"/>
|
||||
<field name="dose_uom"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'pending'"
|
||||
decoration-success="state == 'applied'"
|
||||
decoration-muted="state == 'dismissed'"/>
|
||||
<button name="action_apply" type="object"
|
||||
string="Apply" class="btn-primary"
|
||||
invisible="state != 'pending'" icon="fa-check"/>
|
||||
<button name="action_dismiss" type="object"
|
||||
string="Dismiss"
|
||||
invisible="state != 'pending'" icon="fa-times"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Suggestion Form ===== -->
|
||||
<record id="view_fp_replenishment_suggestion_form" model="ir.ui.view">
|
||||
<field name="name">fp.replenishment.suggestion.form</field>
|
||||
<field name="model">fusion.plating.bath.replenishment.suggestion</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_apply" string="Apply"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'pending'"/>
|
||||
<button name="action_dismiss" string="Dismiss"
|
||||
type="object" class="btn-secondary"
|
||||
invisible="state != 'pending'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="pending,applied"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Context">
|
||||
<field name="bath_id"/>
|
||||
<field name="parameter_id"/>
|
||||
<field name="log_line_id"/>
|
||||
<field name="rule_id"/>
|
||||
</group>
|
||||
<group string="Reading vs Target">
|
||||
<field name="current_value"/>
|
||||
<field name="target_min"/>
|
||||
<field name="target_max"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Suggested Dose">
|
||||
<group>
|
||||
<field name="product_name"/>
|
||||
<field name="dose_amount"/>
|
||||
<field name="dose_uom"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="applied_at"/>
|
||||
<field name="applied_by_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_replenishment_suggestion" model="ir.actions.act_window">
|
||||
<field name="name">Replenishment Suggestions</field>
|
||||
<field name="res_model">fusion.plating.bath.replenishment.suggestion</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -67,12 +67,20 @@
|
||||
<field name="tank_id"/>
|
||||
<field name="process_type_id"/>
|
||||
<field name="facility_id" readonly="1"/>
|
||||
<field name="volume"/>
|
||||
<label for="volume"/>
|
||||
<div class="o_row">
|
||||
<field name="volume" nolabel="1" class="oe_inline"/>
|
||||
<span class="ms-1">L</span>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<field name="makeup_date"/>
|
||||
<field name="makeup_by_id"/>
|
||||
<field name="mto_count" readonly="1"/>
|
||||
<label for="mto_count"/>
|
||||
<div class="o_row">
|
||||
<field name="mto_count" nolabel="1" readonly="1" class="oe_inline"/>
|
||||
<span class="ms-1">turnovers</span>
|
||||
</div>
|
||||
<field name="last_log_date" readonly="1"/>
|
||||
<field name="last_log_status" readonly="1" widget="badge"
|
||||
decoration-success="last_log_status == 'ok'"
|
||||
@@ -172,6 +180,15 @@
|
||||
<field name="process_type_id"/>
|
||||
<field name="facility_id"/>
|
||||
<separator/>
|
||||
<filter string="Pre-Treatments" name="family_pre"
|
||||
domain="[('process_type_id.process_family', '=', 'pre_treatment')]"/>
|
||||
<filter string="Plating Baths" name="family_plating"
|
||||
domain="[('process_type_id.process_family', '=', 'plating')]"/>
|
||||
<filter string="Post-Treatments" name="family_post"
|
||||
domain="[('process_type_id.process_family', '=', 'post_treatment')]"/>
|
||||
<filter string="Strip Baths" name="family_strip"
|
||||
domain="[('process_type_id.process_family', '=', 'strip')]"/>
|
||||
<separator/>
|
||||
<filter string="Operational" name="operational" domain="[('state','=','operational')]"/>
|
||||
<filter string="Under Review" name="review" domain="[('state','=','under_review')]"/>
|
||||
<filter string="Out of Spec" name="oos" domain="[('last_log_status','=','out_of_spec')]"/>
|
||||
@@ -43,6 +43,30 @@
|
||||
action="action_fp_tank"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_fp_racks"
|
||||
name="Racks & Fixtures"
|
||||
parent="menu_fp_operations"
|
||||
action="action_fp_rack"
|
||||
sequence="35"/>
|
||||
|
||||
<menuitem id="menu_fp_replenishment_suggestions"
|
||||
name="Replenishment Suggestions"
|
||||
parent="menu_fp_operations"
|
||||
action="action_fp_replenishment_suggestion"
|
||||
sequence="40"/>
|
||||
|
||||
<menuitem id="menu_fp_replenishment_rules"
|
||||
name="Replenishment Rules"
|
||||
parent="menu_fp_config"
|
||||
action="action_fp_replenishment_rule"
|
||||
sequence="55"/>
|
||||
|
||||
<menuitem id="menu_fp_operator_certifications"
|
||||
name="Operator Certifications"
|
||||
parent="menu_fp_config"
|
||||
action="action_fp_operator_cert"
|
||||
sequence="60"/>
|
||||
|
||||
<!-- ===== CONFIGURATION ===== -->
|
||||
<menuitem id="menu_fp_config"
|
||||
name="Configuration"
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ===== Certification List ===== -->
|
||||
<record id="view_fp_operator_cert_list" model="ir.ui.view">
|
||||
<field name="name">fp.operator.cert.list</field>
|
||||
<field name="model">fp.operator.certification</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-success="state == 'active'"
|
||||
decoration-danger="state == 'expired'"
|
||||
decoration-muted="state == 'revoked'">
|
||||
<field name="employee_id"/>
|
||||
<field name="process_type_id"/>
|
||||
<field name="issued_date"/>
|
||||
<field name="expires_date"/>
|
||||
<field name="issued_by_id" optional="show"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Certification Form ===== -->
|
||||
<record id="view_fp_operator_cert_form" model="ir.ui.view">
|
||||
<field name="name">fp.operator.cert.form</field>
|
||||
<field name="model">fp.operator.certification</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_revoke" string="Revoke"
|
||||
type="object" class="btn-danger"
|
||||
invisible="state != 'active'"
|
||||
confirm="Revoke this certification?"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2><field name="name" readonly="1"/></h2>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id"/>
|
||||
<field name="process_type_id"/>
|
||||
<field name="issued_by_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="issued_date"/>
|
||||
<field name="expires_date"/>
|
||||
<field name="training_record_attachment_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Revocation" invisible="state != 'revoked'">
|
||||
<field name="revoked_reason" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Search ===== -->
|
||||
<record id="view_fp_operator_cert_search" model="ir.ui.view">
|
||||
<field name="name">fp.operator.cert.search</field>
|
||||
<field name="model">fp.operator.certification</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="employee_id"/>
|
||||
<field name="process_type_id"/>
|
||||
<filter name="active" string="Active"
|
||||
domain="[('state', '=', 'active')]"/>
|
||||
<filter name="expired" string="Expired"
|
||||
domain="[('state', '=', 'expired')]"/>
|
||||
<filter name="expiring_soon" string="Expiring within 30 days"
|
||||
domain="[('expires_date', '<=', (context_today() + datetime.timedelta(days=30)).strftime('%Y-%m-%d')),
|
||||
('expires_date', '>=', context_today().strftime('%Y-%m-%d')),
|
||||
('state', '=', 'active')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_employee" string="Operator"
|
||||
context="{'group_by': 'employee_id'}"/>
|
||||
<filter name="group_process" string="Process"
|
||||
context="{'group_by': 'process_type_id'}"/>
|
||||
<filter name="group_state" string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_operator_cert" model="ir.actions.act_window">
|
||||
<field name="name">Operator Certifications</field>
|
||||
<field name="res_model">fp.operator.certification</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_operator_cert_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend employee form with a smart list of certs -->
|
||||
<record id="view_hr_employee_fp_certs" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.fp.certs</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating Certifications" name="fp_certs">
|
||||
<field name="x_fc_certification_ids"
|
||||
context="{'default_employee_id': id}">
|
||||
<list editable="bottom">
|
||||
<field name="process_type_id"/>
|
||||
<field name="issued_date"/>
|
||||
<field name="expires_date"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
132
fusion_plating/fusion_plating/views/fp_rack_views.xml
Normal file
132
fusion_plating/fusion_plating/views/fp_rack_views.xml
Normal file
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ===== List ===== -->
|
||||
<record id="view_fp_rack_list" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.rack.list</field>
|
||||
<field name="model">fusion.plating.rack</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-danger="state == 'needs_strip'"
|
||||
decoration-warning="state == 'stripping'"
|
||||
decoration-muted="state == 'retired'">
|
||||
<field name="name"/>
|
||||
<field name="rack_type"/>
|
||||
<field name="facility_id"/>
|
||||
<field name="capacity"/>
|
||||
<field name="mto_count"/>
|
||||
<field name="strip_interval_mto"/>
|
||||
<field name="last_stripped_date"/>
|
||||
<field name="strips_count"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'active'"
|
||||
decoration-danger="state == 'needs_strip'"
|
||||
decoration-warning="state == 'stripping'"
|
||||
decoration-muted="state == 'retired'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Form ===== -->
|
||||
<record id="view_fp_rack_form" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.rack.form</field>
|
||||
<field name="model">fusion.plating.rack</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_start_strip"
|
||||
string="Start Strip"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'needs_strip'"/>
|
||||
<button name="action_mark_stripped"
|
||||
string="Mark Stripped"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'stripping'"/>
|
||||
<button name="action_retire"
|
||||
string="Retire"
|
||||
type="object" class="btn-secondary"
|
||||
invisible="state == 'retired'"
|
||||
confirm="Retire this rack permanently?"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="active,needs_strip,stripping"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. RACK-014"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="rack_type"/>
|
||||
<field name="facility_id"/>
|
||||
<field name="capacity"/>
|
||||
<field name="contact_points"/>
|
||||
</group>
|
||||
<group string="Wear & Strip">
|
||||
<field name="mto_count"/>
|
||||
<field name="strip_interval_mto"/>
|
||||
<field name="last_stripped_date"/>
|
||||
<field name="last_stripped_by_id"/>
|
||||
<field name="strips_count"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Kanban ===== -->
|
||||
<record id="view_fp_rack_kanban" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.rack.kanban</field>
|
||||
<field name="model">fusion.plating.rack</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="state">
|
||||
<field name="status_color"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="oe_kanban_card oe_kanban_global_click oe_kanban_color_#{record.status_color.raw_value}">
|
||||
<strong><field name="name"/></strong>
|
||||
<div><field name="rack_type"/> — <field name="facility_id"/></div>
|
||||
<div>MTO: <field name="mto_count"/> / <field name="strip_interval_mto"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Search ===== -->
|
||||
<record id="view_fp_rack_search" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.rack.search</field>
|
||||
<field name="model">fusion.plating.rack</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="facility_id"/>
|
||||
<filter name="needs_strip" string="Needs Strip"
|
||||
domain="[('state', '=', 'needs_strip')]"/>
|
||||
<filter name="active" string="Active"
|
||||
domain="[('state', '=', 'active')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_facility" string="Facility"
|
||||
context="{'group_by': 'facility_id'}"/>
|
||||
<filter name="group_type" string="Type"
|
||||
context="{'group_by': 'rack_type'}"/>
|
||||
<filter name="group_state" string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_rack" model="ir.actions.act_window">
|
||||
<field name="name">Racks & Fixtures</field>
|
||||
<field name="res_model">fusion.plating.rack</field>
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="search_view_id" ref="view_fp_rack_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -64,8 +64,11 @@
|
||||
<page string="Physical">
|
||||
<group>
|
||||
<group>
|
||||
<field name="volume"/>
|
||||
<field name="volume_uom"/>
|
||||
<label for="volume"/>
|
||||
<div class="o_row">
|
||||
<field name="volume" nolabel="1" class="oe_inline"/>
|
||||
<field name="volume_uom" nolabel="1" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="material"/>
|
||||
</group>
|
||||
<group>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!--
|
||||
Fusion Plating — Settings page block.
|
||||
|
||||
Inherits the standard Settings form. The `app` element creates a
|
||||
Fusion Plating section in the left rail; downstream modules
|
||||
(certificates, invoicing, etc.) extend the same `app` with extra
|
||||
blocks via xpath into //app[@name='fusion_plating'].
|
||||
-->
|
||||
<record id="res_config_settings_view_form_fp_core" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.plating.core</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Plating" string="Fusion Plating"
|
||||
name="fusion_plating"
|
||||
groups="fusion_plating.group_fusion_plating_manager">
|
||||
<block title="Regional Settings"
|
||||
name="fp_regional_settings"
|
||||
help="Defaults applied to dashboards, reports, and emails when a user has no personal preference set.">
|
||||
<setting id="fp_default_timezone"
|
||||
string="Default Timezone"
|
||||
help="Timezone used to display times in dashboards, PDFs, and notification emails. Detected automatically when Fusion Plating is installed; change it any time. Individual users can still override this from their own profile (Preferences > Localization).">
|
||||
<field name="x_fc_default_tz"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user