Compare commits

..

11 Commits

Author SHA1 Message Date
gsinghpal
ba7c028c30 CHANGES 2026-06-04 09:49:51 -04:00
gsinghpal
41ce3784d7 fix(fusion_plating_reports): page-break-inside on CoC part rows
Final-review follow-up: per the entech wkhtmltopdf rule, page-break-inside
must sit on each <tr>, not just the <table>, so a large multi-part cert
can't split a part row mid-row under the company header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:59:29 -04:00
gsinghpal
98873c4e39 feat(fusion_plating): cert backfill migration + version bumps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:54:54 -04:00
gsinghpal
28e5e7f9de feat(fusion_plating_jobs): group WOs by recipe step structure
Replace the old 5-tuple (recipe.id, part, spec, thickness, serial) grouping
key with a structural signature so multiple parts that share the same recipe
step tree (ENP clones) collapse onto one combined work order. Add three
helpers: _fp_recipe_signature, _fp_line_express_signature, _fp_line_group_key.
Add TransactionCase test covering merge, non-merge, and masking-split cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:49:51 -04:00
gsinghpal
c71c60350b feat(fusion_plating_jobs): traveller lists all parts in the batch
Additive 'Batch parts' roster in the Item Information cell, shown when a
WO covers 2+ distinct parts (the labeled block still details the primary
part). Keeps the existing table layout intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:41:16 -04:00
gsinghpal
ba1e15da07 feat(fusion_plating_reports): CoC parts table loops part_line_ids 2026-06-03 22:39:20 -04:00
gsinghpal
f1bf5b214c feat(fusion_plating_jobs): multi-part cert creation + requirement union
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:37:35 -04:00
gsinghpal
983e576fdc feat(fusion_plating_certificates): Parts page on certificate form 2026-06-03 22:28:27 -04:00
gsinghpal
7cbf4f25df feat(fusion_plating_certificates): add fp.certificate.part child model + ACL
Adds the fp.certificate.part model (one row per part on a combined CoC),
the part_line_ids O2M on fp.certificate, and ACL rows for all three
plating roles. No views yet — Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:27:00 -04:00
gsinghpal
e35c120af8 docs(fusion_plating): implementation plan - WO grouping by recipe + combined CoC
8 bite-sized tasks (cert part-line model -> form -> creation -> CoC report
-> traveller -> grouping switch -> migration -> verify). Cert multi-part
support lands before the grouping flip so it is never a compliance
regression. Tests are committed TransactionCase artifacts run on an
Enterprise env (local Community cannot install fusion_plating); plus a
read-only entech signature smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:01:12 -04:00
gsinghpal
e34892f5c0 docs(fusion_plating): spec - WO grouping by recipe structure + combined multi-part CoC
Group SO lines into one fp.job per distinct plating process (identical
recipe step structure) instead of one WO per line; make the Certificate
of Conformance multi-part via a new fp.certificate.part child model + CoC
parts-table loop + migration backfill.

Grounded in a read-only entech audit: 13 WOs -> 4 on real orders;
per-part recipe clones are structurally identical (same node_type +
kind_code + name sequence). cloned_from_id/process_type_id are empty on
existing data, so grouping keys off the step structure.

Phase 1 (this spec): grouping + combined cert + report + traveller +
migration. Phase 2 (deferred): per-part thickness + per-part stickers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:52:50 -04:00
31 changed files with 3017 additions and 507 deletions

View File

@@ -98,8 +98,6 @@
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/product_labor_data.xml', 'data/product_labor_data.xml',
'data/service_rate_products.xml',
'data/service_rate_data.xml',
'wizard/status_change_reason_wizard_views.xml', 'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml', 'views/res_company_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',

View File

@@ -1,108 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CALL-OUTS -->
<record id="rate_callout_standard_normal" model="fusion.service.rate">
<field name="name">Standard Service Call</field>
<field name="code">callout_standard_normal</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">95.0</field>
<field name="product_id" ref="product_callout_standard_normal_product_variant"/>
<field name="sequence">10</field>
</record>
<record id="rate_callout_standard_rush" model="fusion.service.rate">
<field name="name">Rush Service Call (Standard)</field>
<field name="code">callout_standard_rush</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
<field name="product_id" ref="product_callout_standard_rush_product_variant"/>
<field name="sequence">11</field>
</record>
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
<field name="name">After-Hours Service Call (Standard)</field>
<field name="code">callout_standard_afterhours</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
<field name="product_id" ref="product_callout_standard_afterhours_product_variant"/>
<field name="sequence">12</field>
</record>
<record id="rate_callout_lift_normal" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Service Call</field>
<field name="code">callout_lift_normal</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">160.0</field>
<field name="product_id" ref="product_callout_lift_normal_product_variant"/>
<field name="sequence">20</field>
</record>
<record id="rate_callout_lift_rush" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Rush Call</field>
<field name="code">callout_lift_rush</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
<field name="product_id" ref="product_callout_lift_rush_product_variant"/>
<field name="sequence">21</field>
</record>
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating After-Hours Call</field>
<field name="code">callout_lift_afterhours</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
<field name="product_id" ref="product_callout_lift_afterhours_product_variant"/>
<field name="sequence">22</field>
</record>
<!-- LABOUR -->
<record id="rate_labour_onsite" model="fusion.service.rate">
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
<field name="rate_kind">labour</field><field name="category">standard</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
<field name="product_id" ref="product_labour_onsite_product_variant"/><field name="sequence">30</field>
</record>
<record id="rate_labour_lift" model="fusion.service.rate">
<field name="name">Labour — Lift &amp; Elevating</field><field name="code">labour_lift</field>
<field name="rate_kind">labour</field><field name="category">lift</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
<field name="product_id" ref="product_labour_lift_product_variant"/><field name="sequence">31</field>
</record>
<record id="rate_labour_inshop" model="fusion.service.rate">
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
<field name="product_id" ref="product_labor_hourly_product_variant"/><field name="sequence">32</field>
</record>
<!-- TRAVEL -->
<record id="rate_per_km" model="fusion.service.rate">
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
<field name="rate_kind">travel</field><field name="category">na</field>
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
<field name="product_id" ref="product_per_km_product_variant"/><field name="sequence">40</field>
</record>
<!-- DELIVERY / PICKUP -->
<record id="rate_delivery_local" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">35.0</field>
<field name="product_id" ref="product_delivery_local_product_variant"/><field name="sequence">50</field>
</record>
<record id="rate_delivery_outside" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">60.0</field>
<field name="product_id" ref="product_delivery_outside_product_variant"/><field name="sequence">51</field>
</record>
<record id="rate_setup_stairlift" model="fusion.service.rate">
<field name="name">Stairlift — Delivery &amp; Set-up</field><field name="code">setup_stairlift</field>
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">300.0</field>
<field name="product_id" ref="product_setup_stairlift_product_variant"/><field name="sequence">52</field>
</record>
</data>
</odoo>

View File

@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Call-outs (unit) -->
<record id="product_callout_standard_normal" model="product.template">
<field name="name">Service Call — Standard</field>
<field name="default_code">SVC-STD</field>
<field name="type">service</field>
<field name="list_price">95.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_rush" model="product.template">
<field name="name">Service Call — Standard Rush</field>
<field name="default_code">SVC-STD-RUSH</field>
<field name="type">service</field>
<field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_afterhours" model="product.template">
<field name="name">Service Call — Standard After-Hours</field>
<field name="default_code">SVC-STD-AH</field>
<field name="type">service</field>
<field name="list_price">140.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_normal" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating</field>
<field name="default_code">SVC-LIFT</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_rush" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating Rush</field>
<field name="default_code">SVC-LIFT-RUSH</field>
<field name="type">service</field>
<field name="list_price">185.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_afterhours" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating After-Hours</field>
<field name="default_code">SVC-LIFT-AH</field>
<field name="type">service</field>
<field name="list_price">205.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Labour (hour) -->
<record id="product_labour_onsite" model="product.template">
<field name="name">Labour — On-Site</field>
<field name="default_code">LAB-ONSITE</field>
<field name="type">service</field>
<field name="list_price">85.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_lift" model="product.template">
<field name="name">Labour — Lift &amp; Elevating</field>
<field name="default_code">LAB-LIFT</field>
<field name="type">service</field>
<field name="list_price">110.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Travel (unit; qty = km x 2) -->
<record id="product_per_km" model="product.template">
<field name="name">Travel — per km (2-way)</field>
<field name="default_code">SVC-KM</field>
<field name="type">service</field>
<field name="list_price">0.70</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Delivery / pickup (unit) -->
<record id="product_delivery_local" model="product.template">
<field name="name">Delivery / Pickup — Local</field>
<field name="default_code">DEL-LOCAL</field>
<field name="type">service</field><field name="list_price">35.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_outside" model="product.template">
<field name="name">Delivery / Pickup — Outside Local Area</field>
<field name="default_code">DEL-OUT</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_rush" model="product.template">
<field name="name">Rush Pickup / Delivery</field>
<field name="default_code">DEL-RUSH</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_liftchair" model="product.template">
<field name="name">Lift Chair — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-LIFTCHAIR</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_hospitalbed" model="product.template">
<field name="name">Hospital Bed — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-BED</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_stairlift" model="product.template">
<field name="name">Stairlift — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_removal_stairlift" model="product.template">
<field name="name">Stairlift — Removal</field>
<field name="default_code">RMV-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -26,5 +26,4 @@ from . import ai_agent_ext
from . import dashboard from . import dashboard
from . import res_partner from . import res_partner
from . import technician_task from . import technician_task
from . import page11_sign_request from . import page11_sign_request
from . import service_rate

View File

@@ -1,81 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class FusionServiceRate(models.Model):
_name = 'fusion.service.rate'
_description = 'Field Service Rate'
_order = 'sequence, rate_kind, category, timing'
name = fields.Char(string='Name', required=True)
code = fields.Char(
string='Code', required=True, index=True,
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
)
rate_kind = fields.Selection([
('callout', 'Service Call-out'),
('labour', 'Labour'),
('travel', 'Travel / per-km'),
('delivery', 'Delivery / Pickup'),
('other', 'Other'),
], string='Kind', required=True, default='callout')
category = fields.Selection([
('standard', 'Standard'),
('lift', 'Lift & Elevating'),
('na', 'N/A'),
], string='Category', default='na')
timing = fields.Selection([
('normal', 'Normal'),
('rush', 'Rush'),
('afterhours', 'After-Hours'),
('na', 'N/A'),
], string='Timing', default='na')
in_shop = fields.Boolean(string='In-Shop')
product_id = fields.Many2one(
'product.product', string='Invoice Product', required=True, ondelete='restrict',
help='Product used on the sale-order line (description, tax, income account).',
)
price = fields.Monetary(
string='Rate', required=True, currency_field='currency_id',
help='Editable price used on the SO line and the on-screen estimate.',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit = fields.Selection([
('fixed', 'Flat'),
('per_hour', 'Per hour'),
('per_km', 'Per km'),
], string='Unit', required=True, default='fixed')
adds_per_km = fields.Boolean(
string='Adds per-km travel',
help='Call-outs billed as $X + per-km \xd7 2-way (rush / after-hours).',
)
included_labour_min = fields.Integer(
string='Included labour (min)', default=0,
help='Free labour minutes bundled into a service call (e.g. 30).',
)
active = fields.Boolean(string='Active', default=True)
sequence = fields.Integer(string='Sequence', default=10)
_unique_code = models.Constraint(
'UNIQUE(code)',
'A service-rate code must be unique.',
)
@api.model
def get_callout(self, category, timing, in_shop=False):
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
if in_shop:
return self.browse()
return self.search([
('rate_kind', '=', 'callout'),
('category', '=', category),
('timing', '=', timing),
], limit=1)
@api.model
def get_rate(self, code):
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
return self.search([('code', '=', code)], limit=1)

View File

@@ -3,4 +3,3 @@
from . import test_signed_pages_gate from . import test_signed_pages_gate
from . import test_application_received_wizard from . import test_application_received_wizard
from . import test_dashboard from . import test_dashboard
from . import test_service_rate

View File

@@ -1,55 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceRate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Rate = cls.env['fusion.service.rate']
cls.product = cls.env['product.product'].create({
'name': 'Test Service Product', 'type': 'service',
})
def _make(self, **kw):
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
vals.update(kw)
return self.Rate.create(vals)
def test_get_callout_matches_category_and_timing(self):
r = self._make(code='callout_standard_normal', category='standard', timing='normal', price=95.0)
self._make(code='callout_lift_normal', category='lift', timing='normal', price=160.0)
self.assertEqual(self.Rate.get_callout('standard', 'normal'), r)
def test_get_callout_in_shop_returns_empty(self):
self._make(code='callout_standard_normal_b')
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
def test_get_rate_by_code(self):
r = self._make(code='per_km', rate_kind='travel', category='na', timing='na', unit='per_km', price=0.70)
self.assertEqual(self.Rate.get_rate('per_km'), r)
def test_code_must_be_unique(self):
self._make(code='dup')
with self.assertRaises(Exception):
self._make(code='dup')
self.env.flush_all()
def test_seeded_callouts_exist(self):
# standard normal $95, lift after-hours $205 are the canonical seeds
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
self.assertEqual(std.price, 95.0)
self.assertEqual(std.rate_kind, 'callout')
self.assertTrue(std.product_id)
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
self.assertEqual(lift_ah.price, 205.0)
self.assertTrue(lift_ah.adds_per_km)
def test_seeded_per_km(self):
km = self.env['fusion.service.rate'].get_rate('per_km')
self.assertTrue(km)
self.assertEqual(km.unit, 'per_km')
self.assertEqual(km.price, 0.70)

View File

@@ -0,0 +1,869 @@
# WO Grouping by Recipe + Combined Multi-Part Certificate — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Group sale-order plating lines into one work order (`fp.job`) per distinct plating process, and make the Certificate of Conformance multi-part so a combined WO certifies every part truthfully.
**Architecture:** Spec → [docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md](../specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md). Lines whose resolved recipes share an identical *step structure* (and identical masking/bake toggles) collapse onto one `fp.job`. A new `fp.certificate.part` child model holds one row per SO line; `_fp_create_certificates` fills it; the CoC report loops it. The cert multi-part support lands **before** the grouping switch so flipping the grouping is never a compliance regression.
**Tech Stack:** Odoo 19 (Python ORM, QWeb PDF reports), modules `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`.
---
## Testing model (read this first — the env is unusual)
These modules **cannot install on the local Community box** (`fusion_plating` needs Enterprise deps; `installed=0` on `modsdev`). So:
- **Local per-task gate (always runnable):**
- Python: `docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/<path>.py`
(Adjust the `/mnt/odoo-modules/fusion_plating` prefix if your bind mount differs; `K:\Github\Odoo-Modules``/mnt/odoo-modules`, and the plating modules live under its `fusion_plating/` subdir.)
- XML: `docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/<path>.xml'); print('XML OK')"`
- **Odoo unit tests** (TransactionCase, committed as real artifacts): run on an **Enterprise env where `fusion_plating` is installed**`odoo-trial` (VM 316) if present, otherwise a throwaway **entech clone** (do NOT run `--test-enable -u` against prod `admin`). Command shape:
```
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
- **Live read-only smoke (safe on entech prod):** re-run the recipe-signature audit (Task 8) to confirm SO-30092/30083/30079/30071 collapse to one group each. Read-only — no writes.
- **Write-path smoke (clone / odoo-trial only):** create a test SO with same-structure lines, confirm, check one WO + one multi-part cert + render the CoC PDF.
Every "run the test" step below shows the command; if the Enterprise test env is not yet available, write + commit the test and run the suite at the Task 8 verification gate.
---
## File structure
| File | Module | Responsibility |
|------|--------|----------------|
| `fusion_plating_certificates/models/fp_certificate_part.py` | certificates | NEW — one row per part on a cert. |
| `fusion_plating_certificates/models/fp_certificate.py` | certificates | ADD `part_line_ids` O2M. |
| `fusion_plating_certificates/models/__init__.py` | certificates | import new model. |
| `fusion_plating_certificates/security/ir.model.access.csv` | certificates | ACL for `fp.certificate.part`. |
| `fusion_plating_certificates/views/fp_certificate_views.xml` | certificates | "Parts" notebook page. |
| `fusion_plating_certificates/__manifest__.py` | certificates | version bump. |
| `fusion_plating_jobs/models/fp_job.py` | jobs | requirement union + part-line build in `_fp_create_certificates`. |
| `fusion_plating_jobs/models/sale_order.py` | jobs | grouping signature + key (the switch). |
| `fusion_plating_jobs/report/report_fp_job_traveller.xml` | jobs | Item Information loops all parts. |
| `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py` | jobs | backfill one part-line per existing cert. |
| `fusion_plating_jobs/__manifest__.py` | jobs | version bump. |
| `fusion_plating_jobs/tests/test_wo_recipe_grouping.py` | jobs | NEW — signature + grouping tests. |
| `fusion_plating_jobs/tests/test_combined_cert_creation.py` | jobs | NEW — multi-part cert creation tests. |
| `fusion_plating_reports/report/report_coc.xml` | reports | parts-table loop. |
| `fusion_plating_reports/__manifest__.py` | reports | version bump. |
> **Migration location note:** the spec listed the backfill under `fusion_plating_certificates`. It is **moved to `fusion_plating_jobs`** here because the backfill reads `x_fc_job_id` (a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). The `fp.certificate.part` table is created by the certificates upgrade, which Odoo runs first.
**Build order:** cert model → cert form → cert creation → CoC report → traveller → **grouping switch (last)** → migration + verify. This way the multi-part cert is ready before any WO ever carries multiple parts.
---
### Task 1: `fp.certificate.part` model + `part_line_ids` + ACL
**Files:**
- Create: `fusion_plating_certificates/models/fp_certificate_part.py`
- Modify: `fusion_plating_certificates/models/fp_certificate.py` (add O2M near the existing `thickness_reading_ids` at line 87)
- Modify: `fusion_plating_certificates/models/__init__.py`
- Modify: `fusion_plating_certificates/security/ir.model.access.csv`
- [ ] **Step 1: Create the model**
```python
# fusion_plating_certificates/models/fp_certificate_part.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# One row per part on a Certificate of Conformance. A work order can
# cover several parts that share the same plating process (see
# fusion_plating_jobs sale_order._fp_line_group_key); the combined CoC
# lists each part with its own identity + spec + quantities.
from odoo import fields, models
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).')
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec')
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')
```
- [ ] **Step 2: Register the import**
In `fusion_plating_certificates/models/__init__.py`, add (alphabetical / near the other cert imports):
```python
from . import fp_certificate_part
```
- [ ] **Step 3: Add the O2M on `fp.certificate`**
In `fusion_plating_certificates/models/fp_certificate.py`, immediately after the `thickness_reading_ids` field (line 87-89):
```python
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
```
- [ ] **Step 4: Add ACL rows**
Append to `fusion_plating_certificates/security/ir.model.access.csv` (mirror the existing `fp.certificate` group grants):
```csv
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
```
- [ ] **Step 5: Static checks**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
```
Expected: no output (clean).
- [ ] **Step 6: Commit**
```bash
git add fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py \
fusion_plating/fusion_plating_certificates/models/fp_certificate.py \
fusion_plating/fusion_plating_certificates/models/__init__.py \
fusion_plating/fusion_plating_certificates/security/ir.model.access.csv
git commit -m "feat(fusion_plating_certificates): add fp.certificate.part child model + ACL"
```
---
### Task 2: "Parts" page on the certificate form
**Files:**
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml` (notebook at line 154)
- [ ] **Step 1: Add the Parts page as the first notebook page**
Insert immediately after `<notebook>` (line 154), before the existing `<page string="Thickness Readings" ...>`:
```xml
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
```
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
git commit -m "feat(fusion_plating_certificates): Parts page on certificate form"
```
---
### Task 3: `_fp_create_certificates` fills part-lines + requirement union
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py` (`_resolve_required_cert_types` ~line 611; `_fp_create_certificates` build of `vals` before `Cert.create(vals)` at line 2784)
- Test: `fusion_plating_jobs/tests/test_combined_cert_creation.py`
- [ ] **Step 1: Write the failing test**
```python
# fusion_plating_jobs/tests/test_combined_cert_creation.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
```
- [ ] **Step 2: Run it (Enterprise test env) — expect FAIL**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: FAIL — `cert.part_line_ids` is empty (creation doesn't fill it yet).
- [ ] **Step 3: Add helper methods on `fp.job`**
Add near `_fp_create_certificates` in `fusion_plating_jobs/models/fp_job.py`:
```python
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc or '',
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
```
- [ ] **Step 4: Fill `part_line_ids` in `_fp_create_certificates`**
In `_fp_create_certificates`, immediately before `cert = Cert.create(vals)` (line 2784), add:
```python
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
```
- [ ] **Step 5: Requirement union over all parts**
In `_resolve_required_cert_types` (Step 1, ~line 611-642), replace the single-part read with a union across all parts on the job. Change the Step-1 block so `wanted` is the union of each line's part-level requirement (falling back to the partner inherit set computed once):
```python
# ---- Step 1 — partner + part baseline (union across all parts) ----
def _partner_inherit_set():
s = set()
p = self.partner_id
if p:
if p.x_fc_send_coc:
s.add('coc')
if p.x_fc_send_thickness_report:
s.add('thickness_report')
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
s.add('nadcap_cert')
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
s.add('mill_test')
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
s.add('customer_specific')
return s
def _explicit_set(req):
return {
'none': set(), 'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
wanted = set()
inherit = None
for part in (parts or [False]):
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
```
Leave Step 2 (recipe suppression) and Step 3 (CoC/thickness bundling) unchanged — they already operate on `wanted`.
- [ ] **Step 6: Run the test — expect PASS**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: PASS.
- [ ] **Step 7: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/fp_job.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
```
Expected: clean.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
git commit -m "feat(fusion_plating_jobs): multi-part cert creation + requirement union"
```
---
### Task 4: CoC report renders the parts table as a loop
**Files:**
- Modify: `fusion_plating_reports/report/report_coc.xml` (tbody at lines 297-321)
- [ ] **Step 1: Replace the single hard-coded row with a loop + fallback**
Replace the `<tbody>...</tbody>` block (lines 297-322) with:
```xml
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference">
<br/><em t-esc="pl.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<tr t-if="not doc.part_line_ids">
<td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div>
<div><t t-esc="pid[1] or '-'"/></div>
<div><t t-esc="pid[2] or '-'"/></div>
</td>
<td>
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference">
<br/><em t-esc="doc.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="doc.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="doc.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</tbody>
```
> Keep `page-break-inside: avoid` on the parent table (line 271-272) unchanged. Each part row is short; the table-level rule already prevents mid-row splits for the typical 1-4 part case.
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_reports/report/report_coc.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_reports/report/report_coc.xml
git commit -m "feat(fusion_plating_reports): CoC parts table loops part_line_ids"
```
---
### Task 5: Traveller lists every part in the batch
**Files:**
- Modify: `fusion_plating_jobs/report/report_fp_job_traveller.xml` (Item Information block, ~lines 116-160)
- [ ] **Step 1: Loop the plating lines in the Item Information cell**
The Item Information `<td>` currently renders `job.part_catalog_id` once (singular). Wrap the per-part rows in a loop over the job's plating lines, falling back to the singular part when no lines are linked. Replace the singular part-number / revision / material / name reads (lines ~127-157) with:
```xml
<t t-set="trav_lines"
t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else job.browse([])"/>
<t t-if="not trav_lines and 'part_catalog_id' in job._fields and job.part_catalog_id">
<t t-set="trav_parts" t-value="[job.part_catalog_id]"/>
</t>
<t t-else="">
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id')"/>
</t>
<t t-foreach="trav_parts" t-as="tp">
<div style="margin-bottom: 2px;">
<strong t-esc="tp.part_number or '—'"/>
<t t-if="'revision' in tp._fields and tp.revision">
<span> Rev <t t-esc="tp.revision"/></span>
</t>
<t t-if="'base_material' in tp._fields and tp.base_material">
<span> · <t t-esc="tp.base_material"/></span>
</t>
<span> · <t t-esc="tp.name or '—'"/></span>
</div>
</t>
```
> This preserves the existing field reads (`part_number`, `revision`, `base_material`, `name`) but emits one line per part. The routing/process table below (one shared recipe) is unchanged. Verify the surrounding `<td>`/column structure still balances after the edit — keep the edit inside the existing Item Information cell.
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
git commit -m "feat(fusion_plating_jobs): traveller lists all parts in the batch"
```
---
### Task 6: Grouping by recipe structural signature (the switch)
**Files:**
- Modify: `fusion_plating_jobs/models/sale_order.py` (`_fp_auto_create_job` groups block, lines 439-470)
- Test: `fusion_plating_jobs/tests/test_wo_recipe_grouping.py`
- [ ] **Step 1: Write the failing tests**
```python
# fusion_plating_jobs/tests/test_wo_recipe_grouping.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
def _recipe(self, name, step_names):
root = self.Node.create({'name': name, 'node_type': 'recipe'})
seq = 10
for sn in step_names:
self.Node.create({
'name': sn, 'node_type': 'step',
'parent_id': root.id, 'sequence': seq})
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
```
- [ ] **Step 2: Run them — expect FAIL**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: FAIL — `_fp_recipe_signature` does not exist yet.
- [ ] **Step 3: Add the signature helpers on `sale.order`**
In `fusion_plating_jobs/models/sale_order.py`, add these methods (near `_fp_resolve_recipe_for_line`):
```python
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit)."""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line):
"""WO grouping key. Lines with the same key ride one work order."""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
return ('recipe',
self._fp_recipe_signature(recipe),
self._fp_line_express_signature(line))
```
- [ ] **Step 4: Replace the grouping loop**
In `_fp_auto_create_job`, replace the `groups`-building block (lines 445-470, the `unrecipe_idx`/5-tuple-key logic) with:
```python
# Group by recipe structural signature (+ per-line masking/bake
# toggles). Lines whose recipes have identical steps collapse onto
# one WO; no-recipe lines stay separate. See spec
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
groups = {}
for line in plating_lines:
key = self._fp_line_group_key(line)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
Everything after (the `ordered_keys = sorted(...)` block at line 473 onward) is unchanged — it still derives `n_groups`, names WOs `WO-<parent>` / `WO-<parent>-NN`, and builds one job per group carrying `sale_order_line_ids`.
- [ ] **Step 5: Run the tests — expect PASS**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: PASS (4 tests).
- [ ] **Step 6: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/sale_order.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
```
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/models/sale_order.py \
fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
git commit -m "feat(fusion_plating_jobs): group WOs by recipe step structure"
```
---
### Task 7: Migration backfill + version bumps
**Files:**
- Create: `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py`
- Modify: `fusion_plating_jobs/__manifest__.py` (`19.0.12.1.6` → `19.0.12.2.0`)
- Modify: `fusion_plating_certificates/__manifest__.py` (`19.0.9.3.0` → `19.0.10.0.0`)
- Modify: `fusion_plating_reports/__manifest__.py` (`19.0.11.34.0` → `19.0.11.35.0`)
- [ ] **Step 1: Write the backfill migration**
```python
# fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'serial': pid[2] or '',
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)
```
- [ ] **Step 2: Bump versions**
`fusion_plating_jobs/__manifest__.py`: `'version': '19.0.12.1.6',` → `'version': '19.0.12.2.0',`
`fusion_plating_certificates/__manifest__.py`: `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',`
`fusion_plating_reports/__manifest__.py`: `'version': '19.0.11.34.0',` → `'version': '19.0.11.35.0',`
- [ ] **Step 3: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
```
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_certificates/__manifest__.py \
fusion_plating/fusion_plating_reports/__manifest__.py
git commit -m "feat(fusion_plating): cert backfill migration + version bumps"
```
---
### Task 8: Verification (Enterprise env + read-only entech smoke)
**Files:** none (verification only).
- [ ] **Step 1: Full suite on the Enterprise test env**
Run:
```
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs,fusion_plating_certificates,fusion_plating_reports \
--stop-after-init --http-port=0 --gevent-port=0
```
Expected: exit 0; the new grouping + cert tests pass; no regressions in existing `fusion_plating_jobs` tests.
- [ ] **Step 2: Read-only signature re-run on entech (prod-safe)**
Confirm the four real orders collapse. In `odoo shell -d admin` on entech (read-only — no commit):
```python
SO = env['sale.order']
for name in ('SO-30092', 'SO-30083', 'SO-30079', 'SO-30071'):
so = SO.search([('name', '=', name)], limit=1)
if not so:
continue
lines = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
keys = {SO._fp_line_group_key(l) for l in lines}
print(name, 'lines=%d' % len(lines), 'groups=%d' % len(keys))
# Expect: each prints groups=1
```
- [ ] **Step 3: Write-path smoke (clone / odoo-trial — NOT prod)**
On a non-prod Enterprise DB: create an SO with 3 lines (2 sharing a structurally-identical recipe, 1 different) for a partner with `x_fc_send_coc=True`; confirm it; verify (a) **2** `fp.job` records, (b) the merged job has 2 `sale_order_line_ids`, (c) closing the merged job produces **one** CoC with **2** `part_line_ids`, (d) the rendered CoC PDF shows 2 part rows, (e) a migrated legacy single-part cert still renders one row.
- [ ] **Step 4: Mark plan complete**
All boxes checked, suite green, entech smoke shows `groups=1` for the four orders → ready to deploy (entech upgrade of the three modules, per the standard deploy recipe in CLAUDE.md).
---
## Self-review (completed by plan author)
- **Spec coverage:** grouping signature (Task 6) ✓; combined cert + per-part lines (Tasks 1-3) ✓; CoC report loop (Task 4) ✓; traveller (Task 5) ✓; migration backfill (Task 7) ✓; requirement union (Task 3) ✓; locked decisions (NC=0 editable, union lists all parts, masking/bake split) encoded in Tasks 3 & 6 ✓. Phase 2 (per-part thickness, per-part stickers) intentionally out of scope.
- **Placeholder scan:** no TBD/TODO; every code step shows complete code; `<enterprise_test_db>` is an explicit env parameter (documented in the Testing model), not a code placeholder.
- **Type/name consistency:** `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key` (Task 6) match their uses; `fp.certificate.part` fields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7); `part_line_ids` used consistently across Tasks 1-4 & 7.
- **Known limitation (documented in code):** two same-structure lines that both have bake instructions but different text merge; the shared bake step carries the last applied line's text. Acceptable for Phase 1.

View File

@@ -0,0 +1,425 @@
# WO Grouping by Recipe + Combined Multi-Part Certificate
**Date:** 2026-06-03
**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`
**Author:** Gurpreet (Nexa Systems Inc.)
**Status:** Approved — ready for implementation plan
## Summary
Today a confirmed sale order with N plating lines creates N work orders
(`fp.job` / "WO-NNN"), even when every line runs the same plating
process. The shop wants **one work order per recipe** — different parts
that go through the same process should ride one traveller and one
physical batch, splitting into separate WOs **only when the process
actually differs**.
The blocker is the **Certificate of Conformance**: a `fp.job` carries a
single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders
exactly one part row. Collapsing four parts onto one WO would certify
only the first and silently ship the other three uncertified — the exact
"silent mis-attestation" the 2026-05-13 sticker spec was built to
prevent.
This spec resolves that by making the **certificate multi-part**: one
combined CoC per WO that lists every part in a table, each with its own
part #, spec, serial, and quantities. The grouping change and the
multi-part cert ship together because neither is safe alone.
## Audit findings (live entech, db=admin, read-only, 2026-06-03)
Pulled the real numbers before designing — they overturned the obvious
"group by `recipe_id`" approach.
| Order | Lines | WOs today | Distinct recipes | WOs after |
|-------|-------|-----------|------------------|-----------|
| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** |
| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** |
| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** |
| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** |
- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines
across those 4 orders collapse from **13 WOs → 4 WOs**.
- **Root cause:** every part gets its own *clone* of a base recipe,
renamed `<BASE> — <part#>` (the ` — <suffix>` is stamped by
`_clone_subtree` in `fp_part_composer_controller.py`). So each line
resolves to a *distinct* `fusion.plating.process.node` record →
grouping by `recipe_id` merges **nothing**.
- The clones are **byte-identical in structure** — 9 (or 11) descendant
nodes, same `node_type` + `kind_id.code` + name in the same order.
Verified across all 4 orders. So merging is **faithful**: every part
follows the identical steps.
- `process_type_id` is **empty** on all of them → not a usable signal.
- `cloned_from_id` exists as a field but is **empty on all 13** lines →
not usable for existing data without a backfill.
- **13 existing `fp.certificate` rows** → migration size.
**Conclusion:** the only signals that work on real data are *identical
step structure* and *shared base-name prefix*. We group by **identical
step structure** (truthful, naming-independent, no backfill).
## Locked decisions (from brainstorming, 2026-06-03)
| Q | Decision |
|---|----------|
| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. |
| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). |
| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. |
| Grouping signal? | **Identical step structure** (recipe structural signature). |
| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. |
## Goals / non-goals
**Goals**
- One WO per distinct plating process; same-process parts share one WO.
- A single combined CoC per WO listing each part's own identity + spec +
quantities.
- No silent loss of any part's certification when parts share a WO.
- Per-part masking/bake differences split the WO (never silently merge).
- Existing WOs and certs keep working unchanged; the 13 existing certs
render identically after migration.
**Non-goals**
- Re-grouping already-created WOs (only new confirmations regroup).
- Removing the per-part recipe-cloning mechanism (root-cause fix to the
Part Composer — separate, larger, riskier; out of scope).
- Per-part thickness rendering, per-part box stickers, per-part issue
gate → **Phase 2** (see below).
- Per-physical-box serial tracking (unchanged from prior specs).
## Architecture
### Phase 1 — compliance-safe MVP
#### Change 1 — Grouping by recipe structural signature
File: `fusion_plating_jobs/models/sale_order.py`, method
`_fp_auto_create_job` (the `groups` block around line 439-470).
Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a
**structural signature** key. New helpers on `sale.order`:
```python
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT name
(carries the per-part ' — <part#>' suffix) and all numeric targets
(thickness/temp/time/voltage) — those are per-part attestation
data captured on the cert, not a reason to split the batch.
Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express override flags that change physical processing
(masking on/off, bake setpoint/duration, etc.). Lines that differ
here must NOT merge even when the recipe structure matches, or the
shared WO would silently drop one part's masking/bake.
The exact field set is enumerated from sale.order.line's Express
Orders fields at implementation time (x_fc_masking_enabled + the
bake override fields); all reads are field-guarded.
"""
F = line._fields
bits = []
for fname in self._FP_EXPRESS_OVERRIDE_FIELDS:
if fname in F:
bits.append((fname, line[fname]))
return tuple(bits)
def _fp_line_group_key(self, line):
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
return ('recipe',
self._fp_recipe_signature(recipe),
self._fp_line_express_signature(line))
```
The grouping loop becomes:
```python
groups = {}
for line in plating_lines:
key = self._fp_line_group_key(line)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
Everything downstream of `groups` is unchanged: `ordered_keys` still
sorts by min line sequence, `n_groups` still drives single-vs-suffixed
WO naming (`WO-<parent>` vs `WO-<parent>-NN`), and the per-group job
create loop already sums qty, carries `sale_order_line_ids`, and copies
SO header fields.
**Representative recipe:** the WO's `recipe_id` is the first line's
recipe in the group. Because every recipe in the group is structurally
identical, step generation (`fp.job.action_confirm`
`_generate_steps_from_recipe`) produces the correct steps for all parts.
**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep
pointing at the first line's values (display + back-compat). The
per-part truth lives in `sale_order_line_ids` and the cert part-lines.
#### Change 2 — `fp.certificate.part` (new child model)
File: `fusion_plating_certificates/models/fp_certificate_part.py` (new).
```python
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one('sale.order.line') # traceability
part_catalog_id = fields.Many2one('fp.part.catalog')
part_number = fields.Char() # snapshot
part_name = fields.Char() # snapshot of catalog .name
description = fields.Char() # customer-facing description snapshot
serial = fields.Char() # comma-joined serial names snapshot
customer_spec_id = fields.Many2one('fusion.plating.customer.spec')
spec_reference = fields.Char() # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer()
nc_quantity = fields.Integer()
# Phase 2: thickness_reading_ids (inverse certificate_part_id)
```
On `fp.certificate`:
```python
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts')
```
Views: add an editable `part_line_ids` list to the certificate form
(so the issuer can review/adjust before issuing). ACL rows for
`fp.certificate.part` mirror `fp.certificate`'s groups (operator read +
manager write, matching the existing cert ACL).
#### Change 3 — `_fp_create_certificates` fills part-lines
File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716).
- **Requirement union** — `_resolve_required_cert_types` currently reads
the *first* part's `certificate_requirement`. Walk **all** plating
lines on the job; union each part's wanted set (part-level override
else partner inherit). Recipe suppression + CoC/thickness bundling are
unchanged (uniform — one recipe per WO).
- **Cert create** — still one cert per resulting type. Cert-level fields
(po_number, customer_job_no, process_description = base recipe name,
certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id)
unchanged. **Legacy singular fields** (part_number, spec_reference,
quantity_shipped, nc_quantity) keep being set from the **first** line
for back-compat.
- **Part-lines** — build one `fp.certificate.part` per plating line on
the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to
lines with a part):
```python
seq = 10
part_cmds = []
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False
part_cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description') else (sol.name or ''),
'serial': ', '.join(sol.x_fc_serial_ids.mapped('name'))
if 'x_fc_serial_ids' in sol._fields else '',
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
vals['part_line_ids'] = part_cmds
```
**Per-part quantities:** `quantity_shipped` defaults to the **line**
qty (naturally per-part). `nc_quantity` defaults to **0** — scrap /
visual rejects are tracked at job level only, not per part, so we do not
auto-split them; the issuer edits per-part NC at issue if needed. The
job-level NC total remains on the cert's legacy `nc_quantity` field.
**Idempotency:** the existing per-type idempotency guard is unchanged;
re-running `_fp_create_certificates` does not duplicate certs or lines.
#### Change 4 — CoC report renders the parts table as a loop
File: `fusion_plating_reports/report/report_coc.xml` (tbody at line
297-321).
```xml
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference"><br/><em t-esc="pl.spec_reference"/></t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<!-- Defensive fallback: legacy cert with no part-lines (should not
occur post-migration) renders the old single row. -->
<tr t-if="not doc.part_line_ids">
... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
</tr>
</tbody>
```
Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level
(uniform), kept cert-level. The Process column shows each part's own
customer-facing description + spec_reference (per 2026-05-28 policy).
`page-break-inside: avoid` stays on each `<tr>` (per CLAUDE.md) so a part
row never splits across a page.
#### Change 5 — Traveller lists all parts
File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`.
The Item Information block today shows one part (`job.part_catalog_id`).
Loop `job.sale_order_line_ids` (plating lines) so the operator sees every
part in the batch with its qty. The routing/process table is unchanged
(one shared recipe). Field reads stay defensively guarded.
#### Change 6 — Migration backfill
File: `fusion_plating_certificates/migrations/<new-version>/post-migrate.py`.
For each existing `fp.certificate` with no `part_line_ids`, create one
part-line from its current singular fields so old certs render
identically:
```python
for cert in env['fp.certificate'].search([]):
if cert.part_line_ids:
continue
pid = cert._fp_resolve_part_identity() # (number, name, serials)
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id
if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False),
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '',
'serial': pid[2] or '',
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
```
Idempotent (skips certs that already have part-lines). 13 certs → 13
single-part certs.
### Phase 2 — per-part refinement (separate plan)
- **Per-part thickness:** add `certificate_part_id` to
`fp.thickness.reading`; associate readings + page-2 Fischerscope PDF
merges per part; render a per-part thickness block under each part row;
extend the `action_issue` thickness gate to require data on each part
that needs thickness.
- **Per-part box stickers:** today's consolidated "Multiple Line Items"
sticker gains per-part detail / per-part labels.
- **Cert form polish:** richer part-line editing UX.
Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and
validated on entech.
## Files touched (Phase 1)
| # | File | Change |
|---|------|--------|
| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. |
| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. |
| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. |
| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. |
| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. |
| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. |
| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. |
| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. |
| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. |
| 10 | `fusion_plating_certificates/migrations/<ver>/post-migrate.py` | Backfill one part-line per existing cert. |
| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. |
## Migration
- New `fp.certificate.part` table created on install/upgrade.
- Post-migrate backfills the 13 existing certs (idempotent).
- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing:
return` guard means only **new** confirmations regroup.
- No re-grouping tool for open orders in Phase 1 (out of scope; can be a
one-off odoo-shell script later if the shop wants it).
## Testing
These modules require Enterprise deps and **cannot install on the local
Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so:
- **Static checks (local):** `pyflakes` on every changed `.py`; lxml
parse on changed XML; `node --check` not needed (no JS).
- **Unit (where installable):** the grouping helpers are pure functions
of a recipe/line — `_fp_recipe_signature` returns equal tuples for two
structurally-identical recipes and unequal for divergent ones;
`_fp_line_group_key` merges same-structure lines and splits on
differing express overrides.
- **Live verification (entech via odoo shell, read-only first):**
1. Re-run the audit signature on SO-30083/30079/30071/30092 →
confirm each collapses to 1 group.
2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process
lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different
processes → 2 WOs.
3. Confirm `_fp_create_certificates` produces one CoC with 4
part-lines; render the CoC PDF → 4 part rows, correct per-part
part#/serial/spec/qty.
4. Render an existing (migrated) single-part cert → identical to
before.
5. A line with masking ON + a line with masking OFF, same recipe →
**2** WOs (express-signature split).
## Edge cases & open questions
| Item | Decision |
|------|----------|
| No-recipe lines | Each its own WO (unchanged). |
| Same recipe structure, different express masking/bake | **Split** (express signature in the key). |
| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. |
| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) |
| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) |
| Job `part_catalog_id` when multi-part | First line (display/back-compat). |
| WO naming | `WO-<parent>` (1 group) / `WO-<parent>-NN` (N groups) — unchanged. |
| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. |
**Confirmed during review (2026-06-03):** the union-cert "list all
shipped parts even if one part opted out" behaviour, and the "per-part
NC defaults to 0, editable at issue" behaviour are both approved.

View File

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

View File

@@ -5,6 +5,7 @@
from . import fp_thickness_reading from . import fp_thickness_reading
from . import fp_certificate from . import fp_certificate
from . import fp_certificate_part
from . import res_config_settings from . import res_config_settings
from . import res_partner from . import res_partner
from . import fp_delivery from . import fp_delivery

View File

@@ -87,6 +87,10 @@ class FpCertificate(models.Model):
thickness_reading_ids = fields.One2many( thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'certificate_id', string='Thickness Readings', 'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
) )
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
# ----- Inline Fischerscope PDF upload (cert-local) ---------------------- # ----- Inline Fischerscope PDF upload (cert-local) ----------------------
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the # The merge pipeline normally pulls the Fischerscope/XDAL PDF from the

View File

@@ -0,0 +1,38 @@
# -*- 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 FpCertificatePart(models.Model):
"""One row per part on a combined Certificate of Conformance.
A work order can cover several parts that share the same plating
process; the combined CoC lists each with its own identity, spec,
and quantities. Fields are snapshots taken at cert-creation time.
"""
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
_rec_name = 'part_number'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True,)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).',)
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec',)
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
# Per-part; the parent fp.certificate keeps cert-level legacy totals.
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')

View File

@@ -11,3 +11,6 @@ access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_t
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1 access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1 access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1 access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fp_thickness_upload_wiz_mgr fp.thickness.upload.wiz.manager model_fp_thickness_upload_wizard fusion_plating.group_fp_manager 1 1 1 1
12 access_fp_thickness_upload_wiz_line_sup fp.thickness.upload.wiz.line.supervisor model_fp_thickness_upload_wizard_line fusion_plating.group_fp_shop_manager_v2 1 1 1 1
13 access_fp_thickness_upload_wiz_line_mgr fp.thickness.upload.wiz.line.manager model_fp_thickness_upload_wizard_line fusion_plating.group_fp_manager 1 1 1 1
14 access_fp_certificate_part_operator fp.certificate.part.operator model_fp_certificate_part fusion_plating.group_fp_technician 1 1 0 0
15 access_fp_certificate_part_supervisor fp.certificate.part.supervisor model_fp_certificate_part fusion_plating.group_fp_shop_manager_v2 1 1 1 0
16 access_fp_certificate_part_manager fp.certificate.part.manager model_fp_certificate_part fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -152,6 +152,21 @@
invisible="trend_alert == 'ok'"/> invisible="trend_alert == 'ok'"/>
</group> </group>
<notebook> <notebook>
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
<page string="Thickness Readings" name="readings"> <page string="Thickness Readings" name="readings">
<field name="thickness_reading_ids"> <field name="thickness_reading_ids">
<list editable="bottom"> <list editable="bottom">

View File

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

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
spec = cert.customer_spec_id if 'customer_spec_id' in cert._fields else False
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'serial': pid[2] or '',
'customer_spec_id': spec.id if spec else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)

View File

@@ -609,38 +609,47 @@ class FpJob(models.Model):
matches the defensive pattern used elsewhere in this file. matches the defensive pattern used elsewhere in this file.
""" """
self.ensure_one() self.ensure_one()
# ---- Step 1 — partner + part baseline ---- # ---- Step 1 — partner + part baseline (union across all parts) ----
req = ( def _partner_inherit_set():
self.part_catalog_id s = set()
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
wanted = set()
p = self.partner_id p = self.partner_id
if p: if p:
if p.x_fc_send_coc: if p.x_fc_send_coc:
wanted.add('coc') s.add('coc')
if p.x_fc_send_thickness_report: if p.x_fc_send_thickness_report:
wanted.add('thickness_report') s.add('thickness_report')
# Three aerospace/defence partner toggles. Field guards if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
# let this module load even if fusion_plating_certificates s.add('nadcap_cert')
# is at an older version that pre-dates the new fields. if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
if ('x_fc_send_nadcap_cert' in p._fields s.add('mill_test')
and p.x_fc_send_nadcap_cert): if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
wanted.add('nadcap_cert') s.add('customer_specific')
if ('x_fc_send_mill_test' in p._fields return s
and p.x_fc_send_mill_test):
wanted.add('mill_test') def _explicit_set(req):
if ('x_fc_send_customer_specific' in p._fields return {
and p.x_fc_send_customer_specific): 'none': set(), 'coc': {'coc'},
wanted.add('customer_specific')
else:
wanted = {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'}, 'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'}) }.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
if not parts:
parts = [False]
wanted = set()
inherit = None
for part in parts:
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
# ---- Step 2 — recipe suppression (suppress-only) ---- # ---- Step 2 — recipe suppression (suppress-only) ----
recipe = self.recipe_id recipe = self.recipe_id
if recipe: if recipe:
@@ -2655,6 +2664,58 @@ class FpJob(models.Model):
self.name, e, self.name, e,
) )
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
# fp_customer_description() is a method (configurator), not a
# field — use hasattr, not a _fields check.
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc,
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
def _fp_create_certificates(self): def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by """Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running _resolve_required_cert_types. Idempotent per type — re-running
@@ -2742,10 +2803,7 @@ class FpJob(models.Model):
# spec_reference is what action_issue blocks on. # spec_reference is what action_issue blocks on.
# Format spec.code + revision for the cert text. # Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields: if spec and 'spec_reference' in Cert._fields:
ref = spec.code or '' ref = self._fp_format_spec_ref(spec)
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
if ref: if ref:
vals['spec_reference'] = ref vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields: if 'customer_spec_id' in Cert._fields:
@@ -2781,6 +2839,10 @@ class FpJob(models.Model):
vals['contact_partner_id'] = contact.id vals['contact_partner_id'] = contact.id
if 'entech_wo_number' in Cert._fields: if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or '' vals['entech_wo_number'] = self.name or ''
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
cert = Cert.create(vals) cert = Cert.create(vals)
self.message_post(body=Markup(_( self.message_post(body=Markup(_(
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer ' '%(t)s <b>%(n)s</b> auto-created (draft). Issuer '

View File

@@ -395,6 +395,66 @@ class SaleOrder(models.Model):
return part.recipe_id return part.recipe_id
return Node return Node
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit).
When the Express fields are absent on a line's module, masking
defaults to True and bake to False, so a non-Express line groups
as masking-on / no-bake.
"""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line, sig_cache=None):
"""WO grouping key. Lines with the same key ride one work order.
`sig_cache` (optional) memoises recipe-id -> signature so a
multi-line SO doesn't re-search the same recipe tree per line.
"""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
if sig_cache is None:
sig = self._fp_recipe_signature(recipe)
else:
if recipe.id not in sig_cache:
sig_cache[recipe.id] = self._fp_recipe_signature(recipe)
sig = sig_cache[recipe.id]
if not sig:
# A recipe with no step nodes has no structure to share —
# don't let empty-tree shells silently merge into one WO.
return ('no_recipe', line.id)
return ('recipe', sig, self._fp_line_express_signature(line))
def _fp_auto_create_job(self): def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines. """Create fp.job(s) from the SO's plating lines.
@@ -436,37 +496,14 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name) _logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return return
# Group by (recipe, part, spec, thickness, serial). Lines that # Group by recipe structural signature (+ per-line masking/bake
# share ALL FIVE collapse into one WO. Bundling lines with # toggles). Lines whose recipes have identical steps collapse onto
# different specs / thicknesses / serials under one WO would # one WO; no-recipe lines stay separate. See spec
# carry the first line's values onto the cert + sticker — # 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {} groups = {}
unrecipe_idx = 0 _sig_cache = {}
for line in plating_lines: for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line) key = self._fp_line_group_key(line, sig_cache=_sig_cache)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_key = (
'x_fc_thickness_range' in line._fields
and (line.x_fc_thickness_range or '').strip()
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
groups[key] = groups.get(key, self.env['sale.order.line']) | line groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO # Order groups by min line sequence so dash-suffixes mirror SO

View File

@@ -142,6 +142,16 @@
<span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/> <span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/>
<strong>S/N:</strong> <strong>S/N:</strong>
<t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t> <t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t>
<!-- Multi-part batch: list every distinct part on this WO
(the labeled block above details the primary part). -->
<t t-set="trav_lines" t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else False"/>
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id') if trav_lines else False"/>
<t t-if="trav_parts and len(trav_parts) &gt; 1">
<br/><strong>Batch parts:</strong>
<t t-foreach="trav_parts" t-as="tp">
<div style="font-size: 7pt;"><span t-esc="tp.part_number or '—'"/><t t-if="'revision' in tp._fields and tp.revision"> Rev <span t-esc="tp.revision"/></t></div>
</t>
</t>
</td> </td>
<td> <td>
<strong> <strong>

View File

@@ -10,3 +10,5 @@ from . import test_autopause_cron
from . import test_post_shop_states from . import test_post_shop_states
from . import test_recipe_cert_suppression from . import test_recipe_cert_suppression
from . import test_order_ship_state from . import test_order_ship_state
from . import test_combined_cert_creation
from . import test_wo_recipe_grouping

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
def test_part_lines_fall_back_to_so_order_line(self):
# Job without an explicit sale_order_line_ids M2M still builds
# one part-line per plating line via the SO order_line fallback.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1)
self.assertEqual(len(cert.part_line_ids), 2,
'falls back to SO order_line when no M2M lines set')

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
# kind_id is required on process.node; reuse any seeded kind so
# node creation doesn't depend on the default lookup resolving.
self.kind = self.env['fp.step.kind'].search([], limit=1)
def _node_vals(self, name, node_type):
v = {'name': name, 'node_type': node_type}
if self.kind:
v['kind_id'] = self.kind.id
return v
def _recipe(self, name, step_names):
root = self.Node.create(self._node_vals(name, 'recipe'))
seq = 10
for sn in step_names:
v = self._node_vals(sn, 'step')
v.update({'parent_id': root.id, 'sequence': seq})
self.Node.create(v)
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')

View File

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

View File

@@ -295,7 +295,26 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <t t-foreach="doc.part_line_ids" t-as="pl">
<tr style="page-break-inside: avoid;">
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference">
<br/><em t-esc="pl.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<tr t-if="not doc.part_line_ids" style="page-break-inside: avoid;">
<td class="text-center" style="line-height: 1.3;"> <td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/> <t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div> <div><t t-esc="pid[0] or '-'"/></div>
@@ -303,11 +322,6 @@
<div><t t-esc="pid[2] or '-'"/></div> <div><t t-esc="pid[2] or '-'"/></div>
</td> </td>
<td> <td>
<!-- Customer-facing description is the cert's
spec / certificate info (client request
2026-05-28). Falls back to the recipe-
derived process_description. spec_reference,
now optional, still prints below when set. -->
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/> <t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/> <t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference"> <t t-if="doc.spec_reference">

799
fusion_schedule/CLAUDE.md Normal file
View File

@@ -0,0 +1,799 @@
# fusion_schedule — Claude Code Instructions
> Module-level guide. The repo-wide Odoo 19 rules in `K:\Github\Odoo-Modules\CLAUDE.md`
> (and the global `K:\Github\CLAUDE.md`) **still apply** — this file only adds what is
> specific to `fusion_schedule`. Read both.
>
> **Companion docs:** [`CODE_MAP.md`](CODE_MAP.md) is the precise symbol-level
> "where-is-what" index (every field/method/route/JS fn/template with line numbers) — use it
> to locate code; use this file for guidance. Open audit findings are tracked in Supabase
> `fusionapps.issues` under project **Fusion Schedule**
> (`576de219-57e6-4596-8c8c-0c093e4cb54a`) and summarised in §16 below.
>
> **Provenance:** this module was originally designed & coded with **Cursor using Claude 4.5
> Opus** (AI-generated), then audited by Claude Code. That shows in the failure profile: the
> Odoo-19 *syntax/idioms* are clean (no deprecated APIs), but the bugs cluster in semantic areas
> that need domain reasoning or a running install to catch — unscoped ORM queries (cross-user
> event merging), timezone handling, copy-paste-drifted duplicates (authenticated vs public
> booking), swallowed exceptions, and untested public/render paths. When extending it, **assume
> plausible-but-unverified until tested on Enterprise.**
---
## 1. What this module is
**Fusion Schedule** (`fusion_schedule`, `__manifest__.py` version **19.0.2.1.0**, author
"Fusion Claims", LGPL-3) is a **multi-account calendar synchronisation hub + portal
booking system** for staff (authorizers / sales reps / technicians) in the Fusion Claims
product family.
Three product surfaces, one engine:
1. **Multi-calendar sync** — a staff user connects any number of **Google** and **Microsoft
Outlook** calendars. A 5-minute cron pulls external events into Odoo `calendar.event`
and pushes Odoo-native events out, so the user has one merged calendar and is "busy on
one → blocked on all".
2. **Portal "My Schedule"** (`/my/schedule`) — a portal dashboard: today's + upcoming
appointments, connected-account management, schedule preferences (work hours / break /
travel buffer / base address), a booking form with a week-calendar preview, **AI slot
suggestions** and **AI day-route optimization**, and travel-time blocking.
3. **Public booking links** (`/schedule/<slug>`) — each user gets a shareable slug; external
visitors (no login) can self-book into the user's free slots and later
cancel/reschedule via a per-event **manage token** (`/schedule/manage/<token>`).
> ⚠️ This is the active **Outlook ↔ Odoo sync** for this deployment — **not** Odoo's native
> `microsoft_calendar`/`google_calendar` sync. The backend calendar UI patch (see §11)
> deliberately **hides** the native sync buttons and substitutes Fusion Schedule's own.
It was originally built in **Cursor** (note the leftover `graphify-out/` artifact — a Cursor
code-graph dump; safe to ignore/delete, not loaded by Odoo). Development now happens in
Claude Code.
---
## 2. Enterprise-only — you cannot install this on local Community
The manifest depends on **`appointment`** (Odoo **Enterprise**), plus `google_account` and
`microsoft_account`. Therefore — like `fusion_portal` and `fusion_repairs` — **it cannot be
installed or tested on local `odoo-modsdev` (Community).** The old
`-d fusion-dev -u <module>` recipe does **not** work here.
Test on an Enterprise environment (a Westin clone is the natural choice since
`fusion_portal` already runs there — see the *Westin Prod* section of the repo `CLAUDE.md`).
There are currently **no automated tests** in this module (`tests/` does not exist).
---
## 3. Dependency map
### 3.1 Hard dependencies (`__manifest__.py` → `depends`)
```
base · portal · website · calendar · appointment · google_account · microsoft_account · fusion_portal
```
- `appointment` — Enterprise. Uses `appointment.type`, `appointment.invite`, and
`appointment_type._prepare_calendar_event_values(...)` to build booking events.
- `calendar` — the core model everything revolves around (`calendar.event` is inherited).
- `google_account` / `microsoft_account` — base OAuth plumbing. **Note:** the module rolls
its *own* OAuth flow (it does not reuse `google_calendar`/`microsoft_calendar` sync). It
only borrows their stored client-id ICP params as a *fallback* (see §10).
- `fusion_portal` — the **only `fusion_*` hard dependency**. This is what transitively pulls
in the whole claims stack: `fusion_portal → fusion_claims` (+ `fusion_tasks`,
`fusion_loaners_management`, `knowledge`). So **`fusion_claims` is a transitive
dependency**, always present at runtime.
### 3.2 Soft dependencies (used via `try/except`, NOT in `depends`)
- **`fusion_api`** (`fusion.api.service`) — preferred broker for the Google Maps key and
OpenAI calls. Not declared in `depends`; every call is wrapped in `try/except` and falls
back to `fusion_claims.*` ICP params, then degrades gracefully. The module still runs if
`fusion_api` is absent.
### 3.3 Reverse dependencies
- **Nothing depends on `fusion_schedule`.** It is a leaf/top module. The only mention
elsewhere is `fusion_repairs/__manifest__.py` which lists "fusion_schedule slots" as a
*deferred / future* integration — not a real dependency today.
```
┌─────────────────┐
│ fusion_schedule │ (leaf — nothing depends on it)
└────────┬────────┘
depends │ soft (try/except, NOT in manifest)
┌─────────────────┼──────────────────────────┐
▼ ▼ ▼
fusion_portal appointment (EE) fusion_api ── fusion.api.service
│ google_account / microsoft_account (Maps key + OpenAI broker)
fusion_claims ── owns the `fusion_claims.*` ICP params reused as fallbacks
│ (+ fusion_tasks, fusion_loaners_management, knowledge)
```
---
## 4. ⭐ Relationship with `fusion_claims` (read this — it's the whole point of the coupling)
`fusion_schedule` **does not modify any `fusion_claims` model or view.** The coupling is
indirect and entirely through shared infrastructure. Five concrete links:
### 4.1 Transitive dependency (stack position)
`fusion_schedule` sits **on top of** the claims stack via `fusion_portal → fusion_claims`.
It assumes the claims/portal data model and the authorizer/sales-rep portal already exist.
### 4.2 Config-parameter namespace reuse (the main runtime link)
The portal pages **borrow `fusion_claims`-owned `ir.config_parameter` values** so the
schedule UI matches the claims portal branding and shares the same API keys. These params
are **defined in `fusion_claims/models/res_config_settings.py`**, *not* here:
| ICP key (owned by fusion_claims) | Used in fusion_schedule for | Where |
|---|---|---|
| `fusion_claims.portal_gradient_start` / `_mid` / `_end` | portal header gradient (brand colour) | `PortalSchedule._get_schedule_values()` |
| `fusion_claims.google_maps_api_key` | Maps/Places/Distance-Matrix key **fallback** | `_get_maps_api_key()` |
| `fusion_claims.ai_api_key` | OpenAI key **fallback** (direct HTTP) | `_call_ai()` |
> If you rename/remove these in `fusion_claims`, the schedule portal silently loses its
> gradient / maps / AI. They are read with defaults, so it won't crash — it just degrades.
### 4.3 The `fusion.api.service` broker (preferred path, fusion_claims-family convention)
`_get_maps_api_key()` and `_call_ai()` first try `request.env['fusion.api.service']`
(from **`fusion_api`**) — the same metered, budget-/rate-limited broker the rest of the
Fusion family uses — with `consumer='fusion_schedule'`. Only if that raises do they fall
back to the `fusion_claims.*` ICP params above. So the order is:
**`fusion_api` broker → `fusion_claims` ICP param → graceful no-op.**
> Two non-obvious facts (detail in [`CODE_MAP.md`](CODE_MAP.md) §9): (1) `get_api_key` returns
> the `group_admin`-gated `key.api_key` on a **non-sudo** recordset, so from a portal/public
> request it likely raises `AccessError` and the **ICP fallback fires every time** — for portal
> callers `fusion_claims.google_maps_api_key` is effectively the real source, not the broker.
> (2) That maps-key param is actually **owned by `fusion_tasks`** (`res_config_settings.py:12`),
> not `fusion_claims`, despite the `fusion_claims.*` prefix — grepping `fusion_claims/` for it
> finds nothing.
### 4.4 Portal tile injection (into fusion_portal, which is built on fusion_claims)
`views/portal_schedule_tile.xml` (`portal_my_home_schedule`, priority 45) inherits
**`fusion_portal.portal_my_home_authorizer`** (which itself inherits `portal.portal_my_home`,
priority 40) and `xpath`s a "My Schedule" card into the authorizer/sales-rep portal home
grid. It reuses fusion_portal's `fc_gradient` template var.
- **`fc_gradient` origin:** set in `fusion_portal/views/portal_templates.xml` as
`portal_gradient or <default green/blue>`, where `portal_gradient` is computed by
fusion_portal's home controller from the same `fusion_claims.portal_gradient_*` params
(§4.2). The tile falls back to the literal default if `fc_gradient` is unset.
- **⚠ Fragile xpath:** the tile anchors on
`//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]`.
If fusion_portal renames the funding-claims route, removes that card, or restructures the
home grid's classes, the tile **silently disappears** (or the view fails to load on `-u`).
Re-check this xpath whenever fusion_portal's home template changes.
### 4.5 The `tz` cookie is populated by fusion_portal
Fusion Schedule's timezone resolution (`_resolve_timezone`) reads a browser **`tz`** cookie
(IANA name). That cookie is set by **`fusion_portal/static/src/js/timezone_detect.js`**
(`tz=<IANA>;path=/;max-age=1yr;SameSite=Lax`) — and, redundantly, by Fusion Schedule's own
booking JS (`setTzCookie` IIFE). So on portal pages the correct timezone flows in **from
fusion_portal**; without that cookie (or a `user.tz`), times fall back to the company
calendar tz, then UTC.
### 4.5 Parallel/overlapping scheduling — they share the `calendar.event` table
`fusion_claims` already has its **own, simpler** scheduling:
- `fusion_claims.schedule.assessment.wizard` (`wizard/schedule_assessment_wizard.py`) — a
*backend* wizard that creates a plain `calendar.event` for an ADP assessment from a
`sale.order` (optional 1-day email alarm). No sync, no portal, no travel logic.
- `technician_task` routing — push notifications + travel time using the **same**
`fusion_claims.google_maps_api_key`.
`fusion_schedule` is the **newer, richer, portal-facing + multi-calendar layer**. Both
write to `calendar.event`, so they **interplay**: an assessment event created by the
fusion_claims wizard for a user who has connected calendars will be picked up by Fusion
Schedule's **cross-calendar push** (it's an unlinked `calendar.event` on the user's partner)
and mirrored to that user's external calendar, and it appears in `/my/schedule`. They are
**complementary, not isolated** — keep that shared table in mind when changing either side.
---
## 5. Data model
All custom fields use the `x_fc_*` prefix (repo convention). Models load in this order
(`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link →
calendar_event → res_users → res_config_settings`.
### 5.1 `fusion.calendar.account` — the OAuth account + sync engine *(god object, ~35 edges)*
`models/fusion_calendar_account.py`. One row per connected external calendar.
| Field | Notes |
|---|---|
| `x_fc_user_id` (m2o res.users, required, cascade) | owner |
| `x_fc_provider` (sel: google/microsoft, required) | |
| `x_fc_email` / `x_fc_name` (compute, stored) | label = "Google — a@b.com" |
| `x_fc_active` (bool) | |
| `x_fc_rtoken` / `x_fc_token` / `x_fc_token_validity` | **`groups='base.group_system'`** — OAuth secrets, admin-only |
| `x_fc_sync_token` | provider delta/sync token (`group_system`). Clear it to force a fresh full sync |
| `x_fc_calendar_id` (default `'primary'`) | |
| `x_fc_last_sync`, `x_fc_sync_status` (active/error/paused), `x_fc_error_message` | |
| `x_fc_link_ids` (o2m → event link) | |
This file is the engine. Key method groups (all on the account record):
- **Credential resolution** `_get_google_client_id/_secret`, `_get_microsoft_*` — dedicated
`fusion_schedule_*` ICP param → native `google_calendar_client_id` / `microsoft_calendar_client_id`.
- **Token mgmt** `_get_valid_token` (1-min skew buffer), `_refresh_token`
`_refresh_google_token` / `_refresh_microsoft_token` (MS may rotate the refresh token —
it's re-saved). On HTTP 400/401 the account is marked `error` and tokens cleared.
- **Code exchange** `_exchange_google_code` / `_exchange_microsoft_code` (called from the
controller callback). `_fetch_google_email` / `_fetch_microsoft_email`.
- **Pull (external → Odoo)** `_sync_pull``_sync_pull_google` / `_sync_pull_microsoft`,
with `_google_request_with_retry` / `_microsoft_request_with_retry` (429/503 + connection
retry, capped). Google initial window **now-14d … now+30d**; subsequent syncs use the
sync token (HTTP 410 → drop token, full resync). MS uses Graph `calendarView/delta`;
delta token expiry (`fullSyncRequired`/`SyncStateNotFound`) → full resync. MS page cap:
2000 events initial / 5000 incremental.
- **Event mapping** `_google_event_to_odoo_vals` / `_microsoft_event_to_odoo_vals` and the
reverse `_odoo_event_to_google` / `_odoo_event_to_microsoft`.
- **Upsert/dedup** `_process_google_event` / `_process_microsoft_event`,
`_find_existing_event` (matches name+start+stop, **includes archived** to reuse), and
`_upsert_event_link`.
- **Push (Odoo → external)** `_sync_push_event` (+ insert/patch/delete per provider).
- **Cross-calendar busy block** `_cross_calendar_push` (see §6.3).
- **Backend RPC** `get_user_accounts_status()`, `sync_current_user()` (called from the
calendar UI patch).
- **Cron** `_cron_sync_all_accounts()`.
- **Teardown** `action_disconnect()` — deletes pushed external events, unlinks rows, pauses.
### 5.2 `fusion.calendar.event.link` — Odoo-event ↔ external-event join
`models/fusion_calendar_event_link.py`. One row per (Odoo event, account).
- `x_fc_event_id` (m2o calendar.event, cascade), `x_fc_account_id` (m2o account, cascade),
`x_fc_external_id` (required), `x_fc_universal_id` (iCalUID — used for cross-provider
dedup), `x_fc_last_synced`, `x_fc_sync_direction` (pull/push/both).
- **Constraint:** `models.Constraint('UNIQUE(x_fc_account_id, x_fc_external_id)')` — an
external event links once per account. (Odoo-19 declarative constraint, per repo rule #9.)
### 5.3 `calendar.event` (inherited)
`models/calendar_event.py`. Adds:
- `x_fc_source_account_id` (m2o account) — set when an event was *pulled* from external;
used for colour-coding the source in the portal.
- `x_fc_is_external` (compute, **stored** from source account).
- `x_fc_link_ids` (o2m → link).
- `x_fc_manage_token` (indexed, `copy=False`) — 32-hex public manage token.
- `x_fc_client_email` / `x_fc_client_phone`.
- `x_fc_address_lat` / `x_fc_address_lng` (Float, digits 10,7) — for travel-time calc.
- `x_fc_travel_minutes_before` (int) and `x_fc_is_travel_block` (bool) — travel placeholder
events generated after booking.
- **`write()` / `unlink()` overrides** push updates/deletions to all linked external
calendars — **unless** `_skip_fc_sync()` is true (context has `no_calendar_sync` or
`dont_notify`). `write()` only pushes when a sync-relevant field changed.
### 5.4 `res.users` (inherited)
`models/res_users.py`. Adds per-staff scheduling config:
- `x_fc_calendar_account_ids` (o2m), `x_fc_schedule_slug` (**`UNIQUE` constraint**),
`x_fc_booking_enabled` (default False).
- Work prefs: `x_fc_work_start` (9.0), `x_fc_work_end` (17.0), `x_fc_break_start` (12.0),
`x_fc_break_duration` (0.5h), `x_fc_travel_buffer` (30 min), `x_fc_home_address` +
`x_fc_home_lat`/`x_fc_home_lng`.
- **`create()` override** auto-generates a slug from the name + 4-hex suffix
(`_generate_schedule_slug`). Every user (including pre-existing ones created elsewhere)
gets a unique public slug.
### 5.5 `res.config.settings` (inherited)
`models/res_config_settings.py`. See §12.
---
## 6. The sync engine — how events flow
### 6.1 Pull (external → Odoo), per account
1. `_get_valid_token()` (refresh if needed).
2. Fetch pages (sync-token delta when available, else the ±window).
3. For each event: cancelled/removed → archive local + unlink the link row; otherwise
**upsert** with a 3-tier dedup ladder:
- existing link for `(account, external_id)` → update in place;
- else existing link by **iCalUID** (cross-provider/same-event) → relink;
- else `_find_existing_event` by name+start+stop (incl. archived) → reuse + relink;
- else **create** a new `calendar.event` (owner partner attached) + new link.
4. Persist `x_fc_sync_token`, `x_fc_last_sync`, status.
### 6.2 Push (Odoo → external), per event
`calendar.event.write()` triggers `_sync_push_event` on each linked active account
(insert if no link, patch if linked). New links are tagged `direction='push'`.
### 6.3 Cross-calendar busy-blocking (`_cross_calendar_push`)
Runs in the cron **only for users with ≥2 active accounts**. It finds the user's
**Odoo-native** events (those with **no** existing link) in the window now-1d … now+90d and
pushes them to the **first active account only** (lowest id). Pushing to a single calendar +
only un-linked events together prevent the **pull → push → pull feedback loop** and
cross-calendar duplicates. *This is the "busy on one, blocked on all" mechanism.*
### 6.4 Cron
`data/ir_cron_data.xml``ir_cron_fusion_calendar_sync`, every **5 minutes**, runs as
`base.user_root`, code `model._cron_sync_all_accounts()`. Never-synced accounts are
processed first. Per-account isolation uses `self.env.cr.commit()` / `rollback()` so one bad
account doesn't poison the batch (see §13 footgun about tests).
---
## 7. OAuth connect/callback flow
`/my/schedule/connect/google` and `/connect/microsoft` build the auth URL (scopes:
Google `calendar` + `userinfo.email`, offline + consent; Microsoft `offline_access openid
Calendars.ReadWrite User.Read`), stash a CSRF token in `request.session['fc_oauth_csrf']`,
and encode `{provider, csrf}` into `state`. Redirect URI is always
`<web.base.url>/my/schedule/oauth/callback`.
`/my/schedule/oauth/callback` validates `state` + CSRF, exchanges the code, fetches the
account email, then **find-or-creates** a `fusion.calendar.account` (re-activating a matching
existing one). Requires a **refresh token** — if the provider didn't return one, it errors
asking the user to grant offline access. There's a resilience fallback:
`_find_recently_connected_account` (created in the last 10 min) so a refreshed/timed-out
callback still reports success instead of erroring.
---
## 8. Travel time + AI scheduling
- **Travel time** `_get_travel_time(lat,lng→lat,lng)` — Google **Distance Matrix** (driving,
avoid tolls, depart now), returns minutes or 0 on any failure. `_geocode_address` uses the
Geocoding API (region `ca`).
- **Travel blocks** `_create_travel_blocks(event, staff_user)` — after a booking, looks at
the prev/next located appointments that day and inserts `Travel to …` placeholder events
(`x_fc_is_travel_block=True`, `show_as=busy`) sized to `max(distance-matrix, travel_buffer)`.
- **AI slot suggest** `/my/schedule/ai/suggest` — builds a schedule context, asks OpenAI
(`gpt-4o-mini`) to pick **exactly 3** times **from the provided free-slot list only**
(strict prompt + post-filter against the real slots; never invents times). Used by the
booking form.
- **AI day optimize** `/my/schedule/ai/optimize` — needs ≥2 located appointments; builds a
travel matrix and asks OpenAI for an optimal visiting order + suggested times + savings.
- Both AI calls route through `_call_ai()` (`fusion.api.service.call_openai`
`fusion_claims.ai_api_key` direct-HTTP fallback). Failures degrade to "AI unavailable".
---
## 9. Routes (controllers/portal_schedule.py — `PortalSchedule(CustomerPortal)`)
| Method | Route | Auth | Renders / returns |
|---|---|---|---|
| http | `/my/schedule` | user | `portal_schedule_page` |
| jsonrpc | `/my/schedule/preferences` | user | save work/break/travel/home prefs (geocodes address) |
| http | `/my/schedule/book` | user | `portal_schedule_book` |
| jsonrpc | `/my/schedule/available-slots` | user | free slots for a date |
| jsonrpc | `/my/schedule/week-events` | user | MonSun events for the week strip |
| http POST | `/my/schedule/book/submit` | user | create booking (+ confirmation email + travel blocks) |
| jsonrpc | `/my/schedule/event/cancel` | user | delete own event |
| jsonrpc | `/my/schedule/event/reschedule` | user | move own event |
| jsonrpc | `/my/schedule/ai/suggest` | user | 3 AI slot picks |
| jsonrpc | `/my/schedule/ai/optimize` | user | AI day route |
| http | `/my/schedule/connect/google` · `/connect/microsoft` | user | start OAuth |
| http | `/my/schedule/oauth/callback` | user | finish OAuth |
| jsonrpc | `/my/schedule/disconnect` | user | `action_disconnect` |
| jsonrpc | `/my/schedule/sync-now` | user | `_sync_pull` one account |
| jsonrpc | `/my/schedule/toggle-booking` | user | enable/disable public page |
| http | `/schedule/<slug>` | **public** | `public_booking_page` |
| jsonrpc | `/schedule/<slug>/available-slots` | **public** (csrf=False) | slots |
| http POST | `/schedule/<slug>/book` | **public** (csrf) | public booking |
| http | `/schedule/manage/<token>` | **public** | `public_manage_page` |
| http POST | `/schedule/manage/<token>/cancel` · `/reschedule` | **public** (csrf) | self-service |
| jsonrpc | `/schedule/manage/<token>/available-slots` | **public** (csrf=False) | slots |
Backend (ORM, not HTTP), called from the calendar UI patch:
`fusion.calendar.account.get_user_accounts_status()` and `.sync_current_user()`.
**Slot generation** (`_generate_available_slots`) is the shared core for *all* slot
endpoints: honours the staff user's work hours / break / travel-buffer, intersects with
appointment-type recurring slots, removes past times, and rejects slots that overlap any
existing event **plus the travel buffer** after it.
**Timezone resolution** (`_resolve_timezone`): `user.tz``tz` cookie (set by the frontend
JS / fusion_portal, §4.5) → `company.resource_calendar_id.tz` → UTC.
### 9.1 Authenticated portal vs public booking are TWO separate implementations
This is the single most important structural fact the templates reveal — the two booking
flows do **not** share code and behave differently:
| | Authenticated `/my/schedule/book` | Public `/schedule/<slug>` |
|---|---|---|
| Layout | `portal.portal_layout` (portal chrome + breadcrumbs) | `website.layout` (public site chrome) |
| Slot/booking JS | the **registered asset files** (`portal_schedule_booking.js`, `portal_schedule_accounts.js`) | **inline `<script>`** embedded in `public_booking.xml` (a *second copy* of the slot-render + Places-autocomplete logic) |
| Brand gradient | `portal_gradient` from `fusion_claims.*` params | **hardcoded** `linear-gradient(135deg,#5ba848,#3a8fb7)` — ignores the brand params |
| Event creation | `appointment_type._prepare_calendar_event_values(...)` → a real **appointment** with booking lines/capacity | a **raw `calendar.event`** dict (no appointment lines, no capacity) |
| Slot re-validation on submit | **yes** — re-runs `_generate_available_slots` and rejects stale slots | **no** — trusts the posted `slot_datetime` (double-book risk) |
| Week-calendar preview + AI suggest/optimize | yes | no |
So "fix the booking form" almost always means **edit two places**. Changing slot logic in
the Python `_generate_available_slots` covers both (it's shared server-side), but any
client-side change to slot rendering, autocomplete, or validation must be mirrored between
`portal_schedule_booking.js` and the inline script in `public_booking.xml`.
### 9.2 Two share links, one of them dead
- `schedule_page` computes `share_url = appointment.invite.book_url` (native appointment
share, looked up by `staff_user_ids`) **and** `public_booking_url = <base>/schedule/<slug>`.
Only **`public_booking_url`** is actually rendered (the "Share Booking Link" card/button).
`share_url` is passed to the template but **never used** — and the only seeded
`appointment.invite` (`default_appointment_invite`) has empty `appointment_type_ids`/no
staff, so it would be blank anyway. The slug link is the real share mechanism.
- There is **no `_prepare_home_portal_values` override**, so `/my/schedule` has **no portal
home counter** and no portal breadcrumb registration — the injected tile (§4.4) is the
only discoverable entry point besides the calendar-view cog button (§11).
---
## 10. ICP parameters (full list)
**Owned by this module:**
- OAuth creds: `fusion_schedule_google_client_id`, `fusion_schedule_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `fusion_schedule_microsoft_client_secret`
- Sync: `fusion_schedule_sync_interval` (minutes; **note:** the cron interval is set in XML,
this param is currently informational — changing it does not re-write the cron)
- Defaults: `fusion_schedule.default_work_start` / `_work_end` / `_break_start` /
`_break_duration` / `_travel_buffer`
**Fallbacks read from elsewhere (not owned here):**
- Native Odoo: `google_calendar_client_id`, `google_calendar_client_secret`,
`microsoft_calendar_client_id`, `microsoft_calendar_client_secret`, `web.base.url`
- **fusion_claims namespace:** `fusion_claims.portal_gradient_start/_mid/_end`,
`fusion_claims.google_maps_api_key`, `fusion_claims.ai_api_key` (see §4.2)
---
## 11. Frontend / assets
Registered in `__manifest__.py` `assets`:
**`web.assets_backend`** — patches the native calendar:
- `static/src/views/fusion_calendar_controller.js``patch(AttendeeCalendarController…)`:
loads connected accounts (`get_user_accounts_status`) and adds a "Sync now"
(`sync_current_user`) action.
- `static/src/views/fusion_calendar_controller.xml` — t-inherits
`calendar.AttendeeCalendarController`, **hides** `#header_synchronization_settings` (the
native Google/Outlook sync UI, kept in DOM so other xpaths survive) and injects Fusion's
account chips + sync button + a cog link to `/my/schedule`.
**`web.assets_frontend`** — portal pages:
- `static/src/css/portal_schedule.css`
- `static/src/js/portal_schedule_booking.js` — booking form: sets the `tz` cookie, week
calendar strip, slot fetch + morning/afternoon grouping, AI suggestions, **Google Places
address autocomplete** (`country: 'ca'`, writes hidden lat/lng), submit guards.
- `static/src/js/portal_schedule_accounts.js` — the `/my/schedule` dashboard: reusable
`fusionConfirm` modal + `fusionToast`, disconnect/sync-now, share-link (Web Share /
clipboard), save-preferences, cancel/reschedule modals, AI "optimize my day" modal.
These are **plain IIFE scripts** (not Odoo `Interaction` classes) that bind to **DOM element
IDs** in the QWeb templates. If you rename an element id in the templates you must update the
JS, and vice-versa. Key ids the JS expects: `bookingDate`, `appointmentTypeSelect`,
`slotsContainer/slotsGrid/slotsLoading/noSlots`, `slotDatetime`, `slotDuration`,
`weekCalendar*`, `aiSuggest*`, `clientStreet/clientCity/clientProvince/clientPostal/clientLat/clientLng`,
`rescheduleModal` (+ children), `optimizeModal` (+ children), `schedulePrefsForm`,
`fusionConfirmModal`.
**Templates** (QWeb):
- `views/portal_schedule.xml``portal_schedule_page`, `portal_schedule_book`
(both `portal.portal_layout`).
- `views/public_booking.xml``public_booking_page`, `public_manage_page`
(both `website.layout`; **carry their own inline `<script>`** — see §9.1).
- `views/portal_schedule_tile.xml``portal_my_home_schedule` (the fusion_portal tile).
Frontend wiring notes:
- **Google Maps loader handshake.** The booking templates inject the Maps Places script with
`&callback=initScheduleAddressAutocomplete` (public: `initPublicAddressAutocomplete`). Because
the async script can land before *or* after the IIFE in `portal_schedule_booking.js`, they
coordinate via `window._googleMapsReady` / `window._scheduleAutocompleteInit`. Maps only
loads when a `google_maps_api_key` resolved (§4.2/§4.3) — no key ⇒ no autocomplete, fields
still work manually.
- **Dead toast markup.** `portal_schedule.xml` ships a Bootstrap `#fusionToast` /
`#fusionToastMessage` element, but `portal_schedule_accounts.js` defines its own
`fusionToast()` that builds a fresh `#fusionToastLive` node and **ignores** the template
one. Don't wire new code to `#fusionToast`; call the JS `fusionToast(msg, type)` helper.
- **CSS** (`portal_schedule.css`) is tiny: collapse-chevron rotation, a `.min-width-0`
truncation helper, and mobile sizing for slot buttons / tables / modals. No theming —
colours come from the inline `portal_gradient` styles and Bootstrap utility classes.
---
## 12. Settings UI
`views/res_config_settings_views.xml` adds a **"Fusion Schedule"** app block to
Settings (`base.res_config_settings_view_form`, priority 90) with: Sync Interval, Google
OAuth creds (+ "using Odoo default" hint via `x_fc_google_has_fallback`), Microsoft OAuth
creds (+ fallback hint), and Schedule Defaults (work hours / break / travel buffer, all
`float_time` widgets). The compute fields `x_fc_*_has_fallback` light up when no dedicated
key is set but a native `*_calendar_client_id` exists.
Backend list/form for accounts: `views/fusion_calendar_account_views.xml`
action + menu **Settings → Technical → Calendar Accounts** (`base.menu_custom`).
---
## 13. Security
`security/security.xml` — two record rules (both additive on `base.group_user`):
- users see only their own `fusion.calendar.account` (`x_fc_user_id = user.id`);
- users see only event links for their own accounts.
`security/ir.model.access.csv` — account: full CRUD for `group_user`, none for
`group_public`; event link: CRU for `group_user`, full for `group_system`.
OAuth secrets (`x_fc_rtoken/x_fc_token/x_fc_token_validity/x_fc_sync_token`) are
`groups='base.group_system'` so non-admin users can't read them even on their own rows;
sync code uses `.sudo()` to access them.
---
## 14. Footguns & gotchas (read before editing)
1. **The silent-context flags are load-bearing.** Any time you create/write/unlink a
`calendar.event` *during sync or travel-block creation*, pass `_silent_ctx()` (or at
least `no_calendar_sync=True, dont_notify=True`). Otherwise the `calendar.event`
`write/unlink` overrides will try to **push back to external calendars** → pull → push
feedback loop and/or attendee emails. The whole sync path already does this; mirror it.
2. **MS delta `@removed` reason matters.** `@removed` with reason `'deleted'` (or
`isCancelled`) → archive + unlink. `@removed` with any other reason (typically
`'changed'`) → **return `'skipped'`, do NOT archive** — the event merely drifted out of
the delta window and still exists upstream. This exact distinction was the
`f1cea2fb` bug fix ("stop archiving valid events on @removed=changed"). Don't regress it.
3. **`cr.commit()` / `cr.rollback()` in the cron will raise inside `TransactionCase`.**
Per repo rule #14, Odoo 19 test cursors refuse commit/rollback. There are no tests today,
but if you add any that exercise `_cron_sync_all_accounts` / `sync_current_user`, refactor
to `with self.env.cr.savepoint():` per iteration instead of commit/rollback, or the test
cursor will break.
4. **Declarative SQL objects only** (rule #9): this module already uses
`models.Constraint(...)` for the unique constraints — keep that style, never
`_sql_constraints` or `init()`.
5. **`google_account`/`microsoft_account` ≠ native calendar sync.** Don't "simplify" by
reusing `google_calendar`/`microsoft_calendar` sync — this module intentionally owns its
OAuth + sync and hides the native UI. The native client-id params are only a credential
fallback.
6. **Public endpoints.** `/schedule/<slug>` and `/schedule/manage/<token>` are
`auth='public'`. The manage token is `secrets.token_hex(16)` (32 chars) and
`_get_event_by_token` enforces `len == 32`. Public booking requires both
`x_fc_booking_enabled=True` **and** the user having an `appointment.type` with them as
staff. Keep CSRF on the POST forms; the slot JSON-RPC endpoints are `csrf=False` by design.
7. **`data/appointment_invite_data.xml` is `noupdate=1`** and ships
`default_appointment_invite` with **empty** `appointment_type_ids` — the generic
`/book/book-appointment` share link won't resolve to a real type until configured. The
`/my/schedule` page separately resolves an `appointment.invite` by `staff_user_ids`.
8. **`data/mail_template_data.xml` is NOT `noupdate`** — the booking confirmation template
(`fusion_schedule_booking_confirmation`, on `calendar.event`) reloads on every `-u`.
It renders the manage link from `company.website or get_base_url()`.
9. **`graphify-out/` is a Cursor artifact**, not part of the module. It's not in the
manifest and Odoo never loads it. Safe to ignore or delete; don't treat its
`GRAPH_REPORT.md` as authoritative (it's a heuristic code-graph, ~87% extracted).
10. **Soft-dependency discipline.** Never assume `fusion_api` is installed — keep the
`try/except` + ICP fallback pattern in `_get_maps_api_key` / `_call_ai`. Adding
`fusion_api`/`fusion_claims` to `depends` would change the install graph; only do it
deliberately.
11. **Public booking does NOT re-validate the slot.** `schedule_book_submit` (authenticated)
re-runs `_generate_available_slots` and rejects a slot that's no longer free;
`public_book_submit` does **not** — it trusts the posted `slot_datetime`. Two visitors
hitting the same public slot can double-book. If you tighten this, add the same
re-validation to the public path.
12. **The two booking flows diverge** (§9.1): authenticated bookings are real `appointment`
events (`_prepare_calendar_event_values`); public bookings are raw `calendar.event`
rows. Reporting/automation that assumes every booking is an `appointment.type` booking
will miss public ones. Client-side changes must be made twice (asset file **and** the
inline script in `public_booking.xml`).
13. **`public_booking_page` references `today` but the controller never passes it.** The
template has `t-att-min="today"` on the date picker, yet
`PortalSchedule.public_booking_page()`'s values dict omits `today`. Either the website
render context happens to supply it or the `min` is silently empty (no past-date guard on
the public picker). **Verify / fix** by passing `today` from the controller if you touch
this page. (The authenticated book page correctly uses `now.strftime('%Y-%m-%d')`.)
14. **Public pages ignore the brand gradient.** They hardcode the default green/blue; only
the authenticated portal pages pick up `fusion_claims.portal_gradient_*`. If branding
must reach the public booking page, thread `portal_gradient` through
`public_booking_page` / `public_manage_page` values.
---
## 15. Deployment & history
- Built in **Cursor**; now maintained in Claude Code.
- Lives wherever **`fusion_portal`** lives (the authorizer/sales-rep portal — the **Westin**
Enterprise environment per the repo `CLAUDE.md` *Westin Prod* section). **Verify the
current target before shipping** — there's no in-module deploy note and nothing else
depends on it.
- Notable recent commits touching it:
- `f1cea2fb` — fix: stop archiving valid events on MS `@removed=changed` (the §14.2 bug).
- `747c8142``fusion_portal` renamed from `fusion_authorizer_portal` (this module's
`depends`/tile `inherit_id` already reference the **new** name `fusion_portal`).
- **Renaming the technical name** would require the full DB-rename procedure in repo rule #16
(it's a `fusion_*` module with external IDs, view keys, and a cron baked into the DB).
---
## 16. Audit findings — confirmed bugs, gaps & risks (2026-06-03 deep dive)
These were found by reading the code, not by running it. None are fixed yet — they're
recorded so the next change can address (or consciously accept) them. **The slot `datetime`
emitted by `_generate_available_slots` is UTC** (line 520: `slot_start_utc.strftime(...)`);
hold that fact while reading #1.
### 🔴 Bugs
1. **Timezone double-conversion on 3 of the 4 booking write-paths.** The slot's hidden
`datetime` is **UTC**, but only the authenticated *booking* path consumes it as UTC:
-`schedule_book_submit` (`portal_schedule.py:661`) — `datetime.strptime(...)` used
directly as UTC. **Correct.**
-`schedule_event_reschedule` (`:801803`)
-`public_book_submit` (`:15051507`)
-`public_manage_reschedule` (`:889891`)
The three ❌ paths do `tz.localize(naive).astimezone(utc)` — i.e. they treat an
already-UTC string as *local* and convert **again**, shifting the appointment by the
user's UTC offset. It is **silent when the resolved tz is UTC** (UTC server, no `tz`
cookie / `user.tz`), which is why it can pass casual testing — but with the
`tz`-cookie set by fusion_portal (e.g. `America/Toronto`, §4.5) a reschedule or **any**
public booking lands 45 h off. **Fix:** in those three paths, treat the slot string as
UTC exactly like `schedule_book_submit` (drop the `localize`/`astimezone`).
2. **Google pull is coupled to the server's OS timezone.** In
`_google_event_to_odoo_vals` (`fusion_calendar_account.py:530`):
`start_dt.astimezone(tz=None).replace(tzinfo=None)``astimezone(None)` converts an
aware datetime to the **system local** zone, not UTC. Odoo stores naive **UTC**, so
pulled Google events are correct **only if the container runs UTC**. The Microsoft path
parses as naive-UTC and is fine. **Fix:** `.astimezone(pytz.utc).replace(tzinfo=None)`.
3. **Public booking does not re-validate the slot** (`public_book_submit`) — see §14.11.
Combined with #1 it means the public path can both mis-time *and* double-book.
### 🟠 Gaps between documented intent and implementation
4. **"Busy on one, blocked on all" is enforced at *portal-booking time*, not by syncing
events between external calendars.** `_cross_calendar_push` **skips any event that
already has a link** (`if existing_links: continue`), and every *pulled* event has a
link — so a Google event is **never** pushed into the user's Outlook (and vice-versa).
What actually delivers "blocked on all" is `_generate_available_slots`, which searches
**all** of the user's `calendar.event` rows (everything pulled from every calendar) when
computing free slots — so booking **through `/my/schedule`** respects every connected
calendar. Booking *directly* in Google will not block Outlook. `_cross_calendar_push`
only mirrors **Odoo-native** events to the **first** active account. The manifest's
"busy on one, blocked on all" oversells the cross-external behaviour — state it as
*portal-booking-time* blocking.
### 🟡 Risks / abuse vectors
5. **Slug generation can block user creation.** `res.users.create` sets
`x_fc_schedule_slug` for **every** new user, guarded by `UNIQUE(x_fc_schedule_slug)`. The
4-hex suffix gives 1/65536 collision odds per name-base; a collision raises the
constraint and **fails the whole user-creation transaction** (no retry). Low probability,
high blast radius — consider a retry/uniqueness loop if user-creation volume grows.
6. **Unthrottled public booking.** `/schedule/<slug>/book` creates a `res.partner`, a
`calendar.event`, and force-sends an email for any visitor with **no captcha / rate
limit**. A scripted abuser can spam partners + events + outbound mail. Consider a
throttle / honeypot if the slug links are widely shared.
7. **Synchronous external HTTP inside `calendar.event.write()/unlink()`.** Because
fusion_schedule is the **sole** `calendar.event` extender (verified — see below), its
overrides fire for **every** event in the system. For a *linked* event, a write that
touches a sync field makes a **blocking** Google/Microsoft API call inside the caller's
transaction; a bulk write/delete over many linked events ⇒ N serial HTTP round-trips,
potentially stalling that request/transaction. Keep this in mind before bulk-editing
calendar events in any module.
### 🔬 Deep-dive #5 additions — sync-dedup cluster + public-endpoint security
Found by an adversarial re-read (all verified against code). Full detail + fixes in Supabase
`fusionapps.issues` (project Fusion Schedule). The **dedup cluster (810) is the most serious
— it corrupts data across users**:
8. **🔴 `_find_existing_event` merges events across users + resurrects archived ones.**
`fusion_calendar_account.py:401-417` dedups by **name+start+stop only**, on `.sudo()`
(record rules bypassed), **unscoped** by user/partner/company. Two staff with a same-titled
same-time event (Standup, Lunch, an org-wide invite) → user B's sync **reuses user A's
`calendar.event`** and links B's account onto it; also **reactivates a deliberately-archived
event**. Runs as root in cron → crosses companies. Fix: scope to
`partner_ids in [self.x_fc_user_id.partner_id]` + `x_fc_source_account_id in [self.id, False]`;
never auto-reactivate an event with no surviving link to this account.
9. **🔴 iCalUID cross-link is unscoped.** `fusion_calendar_account.py:482-489` (Google) /
`715-724` (MS) match `x_fc_universal_id` across **all** accounts/users. A real invite sent to
two staff shares one iCalUID → user B's account links onto user A's event; B never gets their
own row. Fix: scope the lookup to `x_fc_account_id.x_fc_user_id = self.x_fc_user_id.id`.
10. **🔴 No per-row isolation in the sync loop.** `_sync_pull_google/_microsoft` loop
`_process_*_event` with no savepoint and write `sync_token` **after** the loop. One row
exception (e.g. an IntegrityError — `_upsert_event_link` branches on `(account,event_id)` at
`:419-445` but the UNIQUE is `(account,external_id)` at `fusion_calendar_event_link.py:32`)
rolls back the whole page and **never advances `sync_token`** → deterministic errors wedge
the account forever. Fix: `with self.env.cr.savepoint():` per row; branch the upsert on
`(account, external_id)`.
11. **🔴 MS delta page-cap stalls large calendars.** `_sync_pull_microsoft` caps at 2000/5000
and `break`s without the `@odata.deltaLink` (`:601-606`), writing back the old token → a
>2000-event window re-fetches the same 2000 forever and never delivers the rest. The
410/`fullSyncRequired` recursion (`:318-321`, `:588-591`) has **no depth guard**.
12. **🟡 Public booking mutates/attaches an existing partner by email.**
`public_book_submit` (`portal_schedule.py:1516-1525`) does
`Partner.search([('email','=ilike', visitor_email)])` then writes `phone` onto the match and
attaches it as an attendee. An anonymous visitor can pollute an arbitrary contact (incl.
staff), pull internal partners into an event, and mail arbitrary addresses. Fix: on the
public path, never mutate/attach a partner matched only by attacker-supplied email.
13. **🟡 Manage-token leaks via redirect URL + no re-validation + no throttle.** The success
redirect puts the 32-char bearer token in an in-page URL query string
(`portal_schedule.py:1590-1594`) → leaks via history + `Referer` to Google Maps assets.
`public_manage_reschedule` (`:876-903`) also skips slot re-validation; public routes are
unthrottled. (Token entropy itself is fine.) Fix: keep the token in the emailed link only,
add `Referrer-Policy: no-referrer`, re-validate, throttle.
14. **🟡 `sync_current_user` commits mid-loop** (`:1097`) — non-atomic inside an interactive
RPC; reports `{success: False}` after already persisting earlier accounts.
15. **🟡 Dead imports** trip pyflakes: `import secrets` (`calendar_event.py:4`) and
`import hashlib` (`controllers/portal_schedule.py:4`) are unused. (`res_users.py` is fine —
it uses `uuid`.)
> Refinement to #4: `_cross_calendar_push` is **also** gated by `len(user_accounts) > 1`
> (`:1149`), so **single-account users never get their Odoo-native events pushed out at all**,
> and the `start >= now-1d` filter excludes all-day events. So even the portal-side mirroring is
> partial.
### 🧱 Deep-dive #6 — install / render / Odoo-19-API correctness (the AI-codegen layer)
**Clean meta-result:** a grep for every repo-documented Odoo-19 anti-pattern came back empty —
no `type="json"`, `groups_id`, `_sql_constraints`, `numbercall`, `useService('rpc')`,
`category_id`, `fields.Date` in settings, or SCSS `@import`; `models.Constraint`/`models.Index`,
`@api.model_create_multi`, the OWL import path, and route types are all **correct** Odoo 19. So
the AI (Cursor + Claude 4.5 Opus) got the *syntax/idioms* right; the defects are semantic
(logic/integration/tz), plus these render/version items:
16. **🟡 `today` undefined on the public booking page.** `public_booking.xml:79`
(`t-att-min="today"`) but `public_booking_page` (`:1418-1426`) never passes `today`
(the authenticated page correctly passes `now`). At minimum the public date picker loses its
min-date guard (visitor can pick a past date → server returns 0 slots). **Confirm on Odoo 19
whether QWeb omits the attr or 500s** — the public page looks untested. Copy-paste drift.
17. **🟡 Confirmation email renders UTC times + wrong language.** `mail_template_data.xml`
`t-out object.start/stop` with the `datetime` widget renders in the **renderer's tz** (UTC on
`force_send` from a portal request) → email shows UTC, not the client's local time. And
`lang = {{ object.partner_ids[:1].lang }}` picks the **first** partner = the **staff** user,
not the client. (Mail body is otherwise rule-17-safe — no `url_encode`/undefined names;
`res.company.website` + `get_base_url()` resolve.)
18. **🟡 Address-autocomplete drift.** The asset JS stores province as full name
(`portal_schedule_booking.js:546`, `long_name` → "Ontario"); the public inline JS stores the
2-letter code (`public_booking.xml:318`, `short_name` → "ON"). Same field, two formats. The
asset version also omits the Places `fields:[...]` filter → Google all-fields billing tier.
19. **🟠 `_prepare_calendar_event_values` signature unverified.** `portal_schedule.py:717-730`
calls this **private Enterprise** method (signature shifted across 16→19). A mismatched kwarg
raises `TypeError`, swallowed by the `except` at `:766` → **authenticated bookings silently
never get created**. The public path builds vals by hand (a tell). **Needs a booking
smoke-test on Enterprise** — couldn't byte-verify (Docker/Odoo source unreachable).
**Version-fragility notes (work now, but verify on Odoo point-upgrades — not logged as bugs):**
- The backend patch xpaths `//div[@id='header_synchronization_settings']`
(`fusion_calendar_controller.xml:10,15`) against `calendar.AttendeeCalendarController`. It
resolves on the deployed version (else the *entire* `web.assets_backend` bundle would be dead),
but a future Odoo restructure of that template would brick the bundle. Prefer a stabler
selector when next touched.
- The `appointment.invite` seed (`appointment_invite_data.xml:8`) has empty
`appointment_type_ids` **and** no `staff_user_ids`, so `schedule_page`'s `share_url`
(`invite.book_url`) never resolves for anyone — the seed is inert (the `/schedule/<slug>` flow
is the real share). Reconcile or drop it.
### ✅ Audit results that came back clean (good to know)
- **No `x_fc_*` field-name collisions.** None of `x_fc_schedule_slug / _booking_enabled /
_work_start / _work_end / _break_start / _travel_buffer / _home_address / _home_lat`
appears in any other module.
- **`calendar.event` is inherited by `fusion_schedule` alone** (whole repo). Its
`write/unlink` overrides are the only custom hooks on that model — but they run for every
calendar event once installed (see risk #7).
- **No conflicting `res.users.create()` override in the dependency chain.** `fusion_portal`
only overrides `_generate_tutorial_articles` / `portal.wizard.user`; `fusion_tasks` adds
`x_fc_is_field_staff / x_fc_start_address / x_fc_tech_sync_id` (no `create`, no overlap).
So the `@api.model_create_multi create()` slug hook chains cleanly via `super()`.
---
## 17. File index
```
fusion_schedule/
├── __manifest__.py # deps, data load order, assets (v19.0.2.1.0)
├── controllers/portal_schedule.py # ALL routes + slot gen + travel + AI + OAuth (~1600 lines)
├── models/
│ ├── fusion_calendar_account.py # OAuth + sync engine (the core)
│ ├── fusion_calendar_event_link.py # Odoo↔external join (unique per account)
│ ├── calendar_event.py # inherit: source/links/manage-token/travel + write/unlink push
│ ├── res_users.py # inherit: slug, booking flag, work prefs, auto-slug
│ └── res_config_settings.py # OAuth creds + sync interval + schedule defaults
├── data/
│ ├── ir_cron_data.xml # 5-min sync cron
│ ├── mail_template_data.xml # booking confirmation email (NOT noupdate)
│ └── appointment_invite_data.xml # default share invite (noupdate, empty types)
├── security/{security.xml, ir.model.access.csv}
├── views/
│ ├── fusion_calendar_account_views.xml # backend list/form + Technical menu
│ ├── res_config_settings_views.xml # Settings app block
│ ├── portal_schedule_tile.xml # tile into fusion_portal.portal_my_home_authorizer
│ ├── portal_schedule.xml # portal_schedule_page + portal_schedule_book
│ └── public_booking.xml # public_booking_page + public_manage_page
├── static/src/
│ ├── css/portal_schedule.css
│ ├── js/portal_schedule_booking.js # booking form + Places autocomplete + AI suggest
│ ├── js/portal_schedule_accounts.js # dashboard modals/toasts + optimize
│ └── views/fusion_calendar_controller.{js,xml} # backend calendar patch
├── utils/__init__.py # empty placeholder
└── graphify-out/ # Cursor code-graph artifact — NOT loaded by Odoo
```

386
fusion_schedule/CODE_MAP.md Normal file
View File

@@ -0,0 +1,386 @@
# fusion_schedule — CODE MAP (where-is-what index)
> Precise symbol-level index for the whole module. Companion to `CLAUDE.md` (which is the
> narrative/guidance doc). **This file = "where is X".** Line numbers are exact at the time of
> writing (2026-06-03); re-grep `def `/`fields.`/`@http.route`/`<template id=` if they drift.
> Audit findings live in `CLAUDE.md §16` and in Supabase `fusionapps.issues`
> (project **Fusion Schedule** = `576de219-57e6-4596-8c8c-0c093e4cb54a`).
## 0. File tree (sizes approximate, by cat -n)
```
fusion_schedule/
├── __manifest__.py 61 deps, data load order, assets (v19.0.2.1.0)
├── __init__.py 3 → controllers, models
├── controllers/
│ ├── __init__.py 2 → portal_schedule
│ └── portal_schedule.py ~1607 PortalSchedule(CustomerPortal): 23 routes + helpers
├── models/
│ ├── __init__.py 6 load order (see below)
│ ├── fusion_calendar_account.py ~1191 sync engine + OAuth + cron (THE core)
│ ├── fusion_calendar_event_link.py 30 Odoo↔external join table
│ ├── calendar_event.py 89 inherit: sync fields + write/unlink push
│ ├── res_users.py 69 inherit: slug + work prefs + auto-slug create()
│ └── res_config_settings.py 74 inherit: OAuth creds + sync interval + defaults
├── data/
│ ├── ir_cron_data.xml 13 5-min sync cron
│ ├── mail_template_data.xml 155 booking confirmation email
│ └── appointment_invite_data.xml 10 default share invite (noupdate)
├── security/
│ ├── security.xml 17 2 record rules
│ └── ir.model.access.csv 5 4 ACL rows
├── views/
│ ├── fusion_calendar_account_views.xml 64 backend list/form/action/menu
│ ├── res_config_settings_views.xml 148 Settings app block
│ ├── portal_schedule_tile.xml 25 tile into fusion_portal home
│ ├── portal_schedule.xml 833 portal_schedule_page + portal_schedule_book
│ └── public_booking.xml 586 public_booking_page + public_manage_page (inline JS)
├── static/src/
│ ├── css/portal_schedule.css 48 responsive helpers only
│ ├── js/portal_schedule_booking.js ~575 booking form (authenticated)
│ ├── js/portal_schedule_accounts.js ~489 dashboard modals/toasts/optimize
│ └── views/fusion_calendar_controller.{js,xml} 68/44 backend AttendeeCalendarController patch
├── utils/__init__.py 1 empty placeholder
└── graphify-out/ — Cursor artifact, NOT loaded by Odoo
```
Model load order (`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link
→ calendar_event → res_users → res_config_settings`.
---
## 1. Models
### 1.1 `fusion.calendar.account` — `models/fusion_calendar_account.py`
`_order = 'x_fc_provider, x_fc_email'`. Module constants (top of file): `TIMEOUT=20` (14),
`MAX_THROTTLE_RETRIES=3` (15), `DEFAULT_RETRY_SECONDS=10` (16); Google endpoints 1923,
Microsoft endpoints 2634.
**Fields**
| line | field | type / notes |
|---|---|---|
| 42 | `x_fc_user_id` | m2o res.users · required · cascade · default=current user · index |
| 46 | `x_fc_provider` | sel google/microsoft · required |
| 50 | `x_fc_email` | char |
| 51 | `x_fc_name` | char · compute `_compute_name` · store |
| 52 | `x_fc_active` | bool · default True |
| 55 | `x_fc_rtoken` | char · **groups=base.group_system** |
| 56 | `x_fc_token` | char · **group_system** |
| 57 | `x_fc_token_validity` | datetime · **group_system** |
| 60 | `x_fc_sync_token` | char · **group_system** (delta/sync token) |
| 61 | `x_fc_calendar_id` | char · default `'primary'` |
| 62 | `x_fc_last_sync` | datetime |
| 63 | `x_fc_sync_status` | sel active/error/paused · default active |
| 68 | `x_fc_error_message` | text |
| 71 | `x_fc_link_ids` | o2m → fusion.calendar.event.link |
**Methods**
| line | method | purpose |
|---|---|---|
| 76 | `_compute_name` | "Provider — email" label |
| 85/92/99/106 | `_get_{google,microsoft}_client_{id,secret}` | creds: `fusion_schedule_*` ICP → native `*_calendar_*` ICP fallback |
| 117 | `_get_valid_token` | return token, refresh if <1 min to expiry |
| 130 | `_refresh_token` | dispatch to provider refresh; on 400/401 mark error + clear |
| 149 / 170 | `_refresh_google_token` / `_refresh_microsoft_token` | OAuth refresh (MS may rotate rtoken) |
| 200 / 213 | `_exchange_{google,microsoft}_code` | code→tokens (called from callback) |
| 232 / 243 | `_fetch_{google,microsoft}_email` | `@api.model` · whoami email |
| 258 | `_sync_pull` | entry: dispatch pull per provider, catch+record errors |
| 293 | `_sync_pull_google` | events.list paging; 410→drop token+full resync; window 14/+30d |
| 362 | `_google_request_with_retry` | GET w/ 429/503 + connection retry |
| 389 | `_silent_ctx` | context flags that suppress mail + re-push (load-bearing) |
| 401 | `_find_existing_event` | dedup by name+start+stop (incl. archived) |
| 419 | `_upsert_event_link` | create/update the join row |
| 447 | `_process_google_event` | upsert one Google event (3-tier dedup) |
| 503 | `_google_event_to_odoo_vals` | ⚠ uses `astimezone(None)` — server-tz bug (CLAUDE §16.2) |
| 550 | `_sync_pull_microsoft` | Graph `calendarView/delta`; page cap 2000/5000 |
| 642 | `_microsoft_request_with_retry` | GET w/ retry |
| 671 | `_process_microsoft_event` | upsert one MS event; `@removed=changed``'skipped'` (don't archive) |
| 738 | `_microsoft_event_to_odoo_vals` | MS dict→Odoo vals |
| 798 | `_fetch_microsoft_event_subject` | fallback fetch when delta omits subject |
| 821 | `_sync_push_event` | push one Odoo event (insert/patch per provider) |
| 870/884/896 | `_google_{insert,patch,delete}_event` | Google write API |
| 908/924/938 | `_microsoft_{insert,patch,delete}_event` | Graph write API |
| 953 / 977 | `_odoo_event_to_{google,microsoft}` | Odoo→external format |
| 1022 | `_cross_calendar_push` | push **unlinked Odoo-native** events to **first** account (CLAUDE §16.4) |
| 1066 | `get_user_accounts_status` | `@api.model` **[backend RPC]** — account chips |
| 1081 | `sync_current_user` | `@api.model` **[backend RPC]** — "Sync now" (commits per account) |
| 1116 | `_cron_sync_all_accounts` | `@api.model` **[cron]** — sync all, then cross-push per multi-acct user |
| 1161 | `action_disconnect` | delete pushed external events, unlink, pause |
### 1.2 `fusion.calendar.event.link` — `models/fusion_calendar_event_link.py`
`_order = 'x_fc_last_synced desc'`. Fields: `x_fc_event_id` (11, m2o calendar.event, cascade),
`x_fc_account_id` (15, m2o account, cascade), `x_fc_external_id` (19, req, index),
`x_fc_universal_id` (22, iCalUID, index), `x_fc_last_synced` (25), `x_fc_sync_direction`
(26, pull/push/both). **Constraint** `_unique_account_external` = `UNIQUE(x_fc_account_id,
x_fc_external_id)` (32).
### 1.3 `calendar.event` (inherit) — `models/calendar_event.py`
**Sole extender of `calendar.event` in the whole repo.** Fields: `x_fc_source_account_id`
(14), `x_fc_is_external` (18, compute+store), `x_fc_link_ids` (21), `x_fc_manage_token`
(24, index, copy=False), `x_fc_client_email` (28), `x_fc_client_phone` (29),
`x_fc_address_lat` (30), `x_fc_address_lng` (31), `x_fc_travel_minutes_before` (32),
`x_fc_is_travel_block` (36). Methods: `_compute_is_external` (42), `_skip_fc_sync` (46),
`unlink` (51, deletes from external), `write` (76, pushes to external) — both gated by
`_skip_fc_sync()` + presence of links. ⚠ external HTTP is synchronous (CLAUDE §16.7).
### 1.4 `res.users` (inherit) — `models/res_users.py`
Fields: `x_fc_calendar_account_ids` (12), `x_fc_schedule_slug` (16), `x_fc_booking_enabled`
(21), `x_fc_work_start` (26), `x_fc_work_end` (30), `x_fc_break_start` (34),
`x_fc_break_duration` (38), `x_fc_travel_buffer` (42), `x_fc_home_address` (46),
`x_fc_home_lat` (50), `x_fc_home_lng` (51). **Constraint** `_unique_schedule_slug` =
`UNIQUE(x_fc_schedule_slug)` (53). Methods: `create` (59, `@api.model_create_multi`,
auto-slug — ⚠ collision risk CLAUDE §16.5), `_generate_schedule_slug` (66).
### 1.5 `res.config.settings` (inherit) — `models/res_config_settings.py`
Fields (12): `x_fc_google_client_id` (10), `_secret` (14), `_has_fallback` (18);
`x_fc_microsoft_client_id` (24), `_secret` (28), `_has_fallback` (32);
`x_fc_sync_interval_minutes` (38, **not wired to cron**); `x_fc_default_work_start` (45),
`_work_end` (50), `_break_start` (55), `_break_duration` (60), `_travel_buffer` (65).
Methods: `_compute_google_has_fallback` (72), `_compute_microsoft_has_fallback` (79).
---
## 2. Controller — `controllers/portal_schedule.py` (`PortalSchedule(CustomerPortal)`)
**Helper methods**
| line | method | purpose |
|---|---|---|
| 30 | `_get_schedule_values` | portal gradient (fusion_claims params) + maps key |
| 43 | `_get_user_timezone` | → `_resolve_timezone(env.user)` |
| 46 | `_resolve_timezone` | user.tz → `tz` cookie → company cal → UTC |
| 69 | `_get_appointment_types` | types where current user is staff |
| 75 | `_get_user_prefs` | per-user prefs w/ company-default fallback |
| 101 | `_get_maps_api_key` | `fusion.api.service``fusion_claims.google_maps_api_key` |
| 114 | `_call_ai` | `fusion.api.service.call_openai` → direct OpenAI HTTP |
| 147 | `_get_travel_time` | Google Distance Matrix (min) |
| 178 | `_geocode_address` | Google Geocoding (lat,lng) |
| 200 | `_create_travel_blocks` | insert "Travel to …" placeholder events |
| 425 | `_format_hour` | staticmethod · 13.5 → "1:30 PM" |
| 435 | `_generate_available_slots` | **shared slot core**; emits UTC `datetime` (line 520) |
| 825 | `_get_event_by_token` | manage-token lookup (len==32) |
| 932 | `_build_schedule_context` | AI prompt context builder |
| 1336 | `_find_recently_connected_account` | OAuth callback resilience |
**Routes** (23 total)
| line | verb | path | auth | handler |
|---|---|---|---|---|
| 288 | http | `/my/schedule` | user | `schedule_page` |
| 363 | jsonrpc | `/my/schedule/preferences` | user | `schedule_save_preferences` |
| 397 | http | `/my/schedule/book` | user | `schedule_book` |
| 530 | jsonrpc | `/my/schedule/available-slots` | user | `schedule_available_slots` |
| 560 | jsonrpc | `/my/schedule/week-events` | user | `schedule_week_events` |
| 630 | http POST | `/my/schedule/book/submit` | user | `schedule_book_submit` ✅ tz-correct |
| 777 | jsonrpc | `/my/schedule/event/cancel` | user | `schedule_event_cancel` |
| 792 | jsonrpc | `/my/schedule/event/reschedule` | user | `schedule_event_reschedule` ⚠ tz-bug |
| 834 | http | `/schedule/manage/<token>` | public | `public_manage_page` |
| 860 | http POST | `/schedule/manage/<token>/cancel` | public | `public_manage_cancel` |
| 876 | http POST | `/schedule/manage/<token>/reschedule` | public | `public_manage_reschedule` ⚠ tz-bug |
| 907 | jsonrpc | `/schedule/manage/<token>/available-slots` | public (csrf=False) | `public_manage_slots` |
| 982 | jsonrpc | `/my/schedule/ai/suggest` | user | `schedule_ai_suggest` |
| 1093 | jsonrpc | `/my/schedule/ai/optimize` | user | `schedule_ai_optimize` |
| 1155 | http | `/my/schedule/connect/google` | user | `connect_google` |
| 1192 | http | `/my/schedule/connect/microsoft` | user | `connect_microsoft` |
| 1230 | http | `/my/schedule/oauth/callback` | user | `oauth_callback` |
| 1356 | jsonrpc | `/my/schedule/disconnect` | user | `schedule_disconnect` |
| 1370 | jsonrpc | `/my/schedule/sync-now` | user | `schedule_sync_now` |
| 1398 | http | `/schedule/<slug>` | public | `public_booking_page` |
| 1431 | jsonrpc | `/schedule/<slug>/available-slots` | public (csrf=False) | `public_available_slots` |
| 1465 | http POST | `/schedule/<slug>/book` | public (csrf) | `public_book_submit` ⚠ tz-bug + no re-validate |
| 1602 | jsonrpc | `/my/schedule/toggle-booking` | user | `schedule_toggle_booking` |
---
## 3. Frontend JS
### 3.1 backend patch — `static/src/views/fusion_calendar_controller.js`
`patch(AttendeeCalendarController.prototype)`: `setup`, getters `fusionAccounts` /
`fusionSyncing`, `_loadFusionAccounts` (→ `get_user_accounts_status`), `onFusionSyncNow`
(→ `sync_current_user`). Template `.xml` hides `#header_synchronization_settings`, injects
account chips + sync button + cog→`/my/schedule`.
### 3.2 `static/src/js/portal_schedule_booking.js` (authenticated book page)
`setTzCookie` (4), `getAppointmentTypeId` (35), `truncate` (41), `formatDateStr` (46),
`addDays` (53), `getMonday` (59), `selectDay` (67), `fetchWeekEvents` (77) →
`/my/schedule/week-events`, `navigateWeek` (120), `renderWeekCalendar` (140), `fetchSlots`
(260) → `/my/schedule/available-slots`, `renderGroup` (319, nested), `fetchAiSuggestions`
(364) → `/my/schedule/ai/suggest`, `setupAddressAutocomplete` (516, Google Places).
### 3.3 `static/src/js/portal_schedule_accounts.js` (dashboard)
Utils: `localDateStr` (4), `setTzCookie` (12), `jsonRpc` (21), `fusionConfirm` (30),
`fusionToast` (87, builds `#fusionToastLive` — template `#fusionToast` is dead),
`closeRescheduleModal` (304), `closeOptimizeModal` (474). Event bindings: disconnect (112)
`/disconnect`, sync (141) → `/sync-now`, share (160), save-prefs (186) → `/preferences`,
cancel (231) → `/event/cancel`, reschedule open (274) + date-change (321) +
confirm (375) → `/event/reschedule`, optimize (413) → `/ai/optimize`.
> Public pages (`public_booking_page`, `public_manage_page`) carry their **own inline
> `<script>`** in `public_booking.xml` (a 2nd copy of slot-render + Places autocomplete +
> reschedule) — they do **not** use the files above. See CLAUDE §9.1.
### 3.4 DOM-id contract (templates ↔ JS)
Book page: `bookingDate`, `appointmentTypeSelect`, `slotsContainer/slotsGrid/slotsLoading/
noSlots`, `slotDatetime`, `slotDuration`, `weekCalendar{Container,Loading,Grid,Header,Body,
Empty,Nav}`, `btnPrevWeek/btnNextWeek/weekRangeLabel`, `aiSuggest{Section,Loading,Grid}`,
`btnAiSuggest`, `clientStreet/City/Province/Postal/Lat/Lng`, `btnSubmitBooking`.
Dashboard: `fusionConfirmModal`(+Title/Message/Ok), `rescheduleModal`(+Date/SlotsContainer/
SlotsGrid/EventId/SlotDatetime/EventDuration/EventName/AppTypeId/btnConfirmReschedule),
`optimizeModal`(+Loading/Result/CurrentTravel/NewTravel/Savings/ScheduleList/Error),
`schedulePrefsForm`/`btnSavePrefs`/`prefsSavedMsg`, `btnOptimizeSchedule`, `.js-*` classes.
Public: `publicBookingDate`, `publicSlots*`, `publicSlotDatetime/Duration`, `publicBtnSubmit`,
`publicAppointmentType`, `publicClient*`, `publicReschedule*`.
---
## 4. Templates / data / security / settings
**Templates**
| id | file:line | base layout |
|---|---|---|
| `portal_schedule_page` | portal_schedule.xml:6 | `portal.portal_layout` |
| `portal_schedule_book` | portal_schedule.xml:605 | `portal.portal_layout` |
| `public_booking_page` | public_booking.xml:6 | `website.layout` (+inline JS) |
| `public_manage_page` | public_booking.xml:345 | `website.layout` (+inline JS) |
| `portal_my_home_schedule` | portal_schedule_tile.xml:5 | inherit `fusion_portal.portal_my_home_authorizer` |
| `FusionCalendarController` | fusion_calendar_controller.xml | t-inherit `calendar.AttendeeCalendarController` |
| `res_config_settings_view_form_fusion_schedule` | res_config_settings_views.xml:4 | inherit `base.res_config_settings_view_form` |
**Backend views**`fusion_calendar_account_views.xml`: list (5), form (24),
`action_fusion_calendar_account` (56), `menu_fusion_calendar_account` (63, under
`base.menu_custom`).
**Data**`ir_cron_fusion_calendar_sync` (ir_cron_data.xml:4, 5 min, `_cron_sync_all_accounts`);
`fusion_schedule_booking_confirmation` (mail_template_data.xml:4, model `calendar.event`,
NOT noupdate); `default_appointment_invite` (appointment_invite_data.xml:8, noupdate,
short_code `book-appointment`, empty types).
**Security** — rules `fusion_calendar_account_user_rule` (security.xml:5),
`fusion_calendar_event_link_user_rule` (security.xml:13); ACL: 4 rows in
`ir.model.access.csv` (account: CRUD user / none public; link: CRU user / full system).
---
## 5. Config parameters (`ir.config_parameter`)
**Owned**`fusion_schedule_google_client_id`, `_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `_microsoft_client_secret`,
`fusion_schedule_sync_interval`; `fusion_schedule.default_work_start` / `_work_end` /
`_break_start` / `_break_duration` / `_travel_buffer`.
**Fallback (not owned)**`google_calendar_client_id` / `_secret`,
`microsoft_calendar_client_id` / `_secret`, `web.base.url`; and the fusion_claims namespace
`fusion_claims.portal_gradient_start/_mid/_end`, `fusion_claims.google_maps_api_key`,
`fusion_claims.ai_api_key`.
---
## 6. External HTTP it talks to
- **Google** OAuth (`accounts.google.com/o/oauth2/auth`, `oauth2.googleapis.com/token`),
Calendar v3 (`googleapis.com/calendar/v3`), userinfo, Distance Matrix + Geocoding
(`maps.googleapis.com`). Scopes: `calendar` + `userinfo.email`.
- **Microsoft** OAuth (`login.microsoftonline.com/common/oauth2/v2.0/*`), Graph
(`graph.microsoft.com/v1.0``me/calendarView/delta`, `me/events`, `me`). Scopes:
`offline_access openid Calendars.ReadWrite User.Read`.
- **OpenAI** `api.openai.com/v1/chat/completions` (`gpt-4o-mini`) — fallback only.
---
## 7. Cross-module touchpoints (full detail in CLAUDE §4)
| direction | what | where |
|---|---|---|
| depends ↓ | `fusion_portal` (→ fusion_claims stack) | __manifest__.py:35 |
| inherit ↓ | `fusion_portal.portal_my_home_authorizer` | portal_schedule_tile.xml:6 |
| soft-call ↓ | `fusion.api.service` (fusion_api) | portal_schedule.py:104,117 |
| ICP read ↓ | `fusion_claims.{portal_gradient_*,google_maps_api_key,ai_api_key}` | portal_schedule.py:33-35,111,126 |
| cookie ← | `tz` set by `fusion_portal/.../timezone_detect.js` | portal_schedule.py:_resolve_timezone |
| shared table | `calendar.event` (also written by fusion_claims schedule wizard / appointment) | models/calendar_event.py |
| reverse | **none** (only fusion_repairs lists it as *deferred*) | — |
---
## 8. Audit cross-reference
**19 findings** logged → Supabase `fusionapps.issues`, project **Fusion Schedule**
(`576de219-57e6-4596-8c8c-0c093e4cb54a`), all `status='open'`. Detail + fixes in
`CLAUDE.md §16` (deep dives #1#6). Provenance: AI-generated (Cursor + Claude 4.5 Opus) —
Odoo-19 syntax clean, bugs are semantic. Headlines: (a) timezone double-conversion on `schedule_event_reschedule` /
`public_book_submit` / `public_manage_reschedule` (slot string is UTC but they re-localize);
(b) the **sync-dedup cluster**`_find_existing_event` (`:401`) and the iCalUID lookup
(`:482`/`:715`) are unscoped by user, so same-titled / shared-invite events **merge across
different users** and resurrect archived ones; (c) public booking mutates an existing
`res.partner` by attacker-supplied email.
---
## 9. Consumed contracts — the OTHER side of each cross-module link (integration boundary)
### 9.1 `fusion.api.service` broker (`fusion_api`, **not a manifest dep**)
`request.env['fusion.api.service']`**`KeyError` if `fusion_api` absent** (caught by
fusion_schedule's bare `except` → fallback). 7 models: `fusion.api.service` (AbstractModel,
broker), `fusion.api.{provider,key,consumer,access,usage,user.limit}` + `usage.daily`.
Public methods fusion_schedule uses: `get_api_key(provider_type, consumer, feature)`
`api_service.py:394`; `call_openai(consumer, feature, messages, model)``:278`. **Raises
`UserError` on 14 conditions** (no active provider `:62`; consumer disabled `:129`; access
disabled `:141`; monthly/daily budget `:157/167`; rpm/rpd `:185/194`; user blocked/budget/rpd
`:218/224/234`; no key `:81`; package missing `:280/335`; downstream API error `:319/381`) —
**any** of these (or KeyError) triggers fusion_schedule's ICP fallback. `provider_type` enum:
`openai, anthropic, google_maps, google_oauth, microsoft_oauth, twilio, custom`. Consumer
auto-registers when `fusion_api.auto_detect_consumers` (default True).
> ⚠ **`get_api_key` returns `key.api_key` (a `group_admin`-gated field) on a *non-sudo*
> recordset (`api_service.py:407`).** For a portal/public request (non-admin/public user) this
> likely raises `AccessError` → fusion_schedule's fallback fires **every time** → in practice
> the maps key for portal/public callers comes from `fusion_claims.google_maps_api_key`, not the
> broker. The broker path may effectively never succeed for raw-key access from the portal.
### 9.2 `portal_gradient` / `fc_gradient` / the tile target (`fusion_portal`)
- `portal_gradient` computed in `fusion_portal/controllers/portal_main.py:81-87` from
`fusion_claims.portal_gradient_{start,mid,end}` (defaults `#5ba848/#3a8fb7/#2e7aad`) — **only
set for portal personas** (`is_authorizer/is_sales_rep_portal/is_client_portal/is_technician_portal`).
fusion_schedule computes its **own identical copy** in `portal_schedule.py:33-36`, so its pages
don't need the controller — only the **tile** does (via `fc_gradient`, set at
`portal_templates.xml:10` = `portal_gradient or <default>`).
- **Tile xpath fragility:** the tiles grid is `<div class="row g-3 mb-4">` (`portal_templates.xml:52-295`);
the anchor is the `/my/funding-claims` card (`:277-294`). fusion_schedule's tile xpath
(`portal_schedule_tile.xml:8`) needs **both** the `/my/funding-claims` `<a>` and the exact
`row g-3 mb-4` class triple — change either and the tile **ParseErrors at install** of
fusion_schedule.
- **`tz` cookie** set by `fusion_portal/static/src/js/timezone_detect.js:25`: name `tz`, value
raw IANA (`America/Toronto`), `path=/ max-age=31536000 SameSite=Lax`. Read at
`portal_schedule.py:_resolve_timezone` (2nd priority after `user.tz`).
### 9.3 The borrowed `fusion_claims.*` params — ownership (defaults all match)
| ICP key | owning field | file:line | default |
|---|---|---|---|
| `fusion_claims.portal_gradient_start/_mid/_end` | `fc_portal_gradient_*` | `fusion_claims/.../res_config_settings.py:461-474` | `#5ba848/#3a8fb7/#2e7aad` |
| `fusion_claims.ai_api_key` | `fc_ai_api_key` | `fusion_claims/.../res_config_settings.py:355` | empty |
| `fusion_claims.google_maps_api_key` | `fc_google_maps_api_key` | **`fusion_tasks`/.../res_config_settings.py:12-16** | empty |
> ⚠ The maps key is **owned by `fusion_tasks`, not `fusion_claims`** (the `fusion_claims.*`
> prefix is kept for data continuity). Grepping `fusion_claims/` for it finds nothing. Both
> owners are transitive deps via fusion_portal, so the params are always present.
---
## 10. Sibling scheduling surfaces & how they interact with this module
**Baseline:** fusion_schedule is the **only** `calendar.event` extender; its `write/unlink`
push to external is gated by `_skip_fc_sync()` (context `no_calendar_sync`/`dont_notify`) +
presence of links, and `_cross_calendar_push` (cron) mirrors **unlinked Odoo-native** events
(1d…+90d, on the user's partner) to the **first** account **only if the user has >1 account**.
| Writer | what it creates | interaction with fusion_schedule |
|---|---|---|
| `fusion_claims` `schedule_assessment_wizard.py:186` | 1 `calendar.event`/manual schedule (assessor partner, optional email alarm), **plain create** | eligible for cron mirror; **later edits fire the synchronous `write()` push** |
| `fusion_portal` `portal_assessment.py:1194` | 1 `calendar.event`/public booking (sales-rep partner; sets `accessibility.assessment.calendar_event_id`), **plain sudo create** | same as above |
| `fusion_tasks` `technician_task.py:1572` (`_sync_calendar_event`) | **HIGH volume** — 1 event/task, re-synced on every schedule-field write/create; **writes with `silent_ctx` (`dont_notify=True`)** | synchronous push **suppressed**; external mirror deferred to the 5-min cron. **Protection hinges on `dont_notify` staying in `silent_ctx`** — drop it and every task edit becomes an inline Google/Outlook round-trip |
- **Reverse coupling:** `fusion_tasks` slot scheduler reads `calendar.event` for busy intervals
(`technician_task.py:495-540`) and **excludes its own task-linked events**, so
externally-synced calendar entries (pulled by fusion_schedule) correctly block technician
availability.
- **Repo sweep:** only these **4** modules touch `calendar.event`/appointments; **only
fusion_schedule uses Enterprise `appointment.*`** (the others create raw `calendar.event`).
`fusion_repairs` maintenance booking is still *planned*. Stale vendored copies of the task
engine exist under `Entech Plating/` and `fusion_plating/`**not** the canonical install
path; flag for cleanup.
- **Maps key consumers:** `fusion_tasks` travel-time (`_calculate_travel_time`) and
`fusion_claims` `google_address_autocomplete.js` both read `fusion_claims.google_maps_api_key`
(owned by fusion_tasks) — same key fusion_schedule falls back to.

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
import hashlib
import logging import logging
import secrets import secrets
@@ -796,12 +795,12 @@ class PortalSchedule(CustomerPortal):
if not event.exists() or partner not in event.partner_ids: if not event.exists() or partner not in event.partner_ids:
return {'success': False, 'error': 'Event not found or access denied.'} return {'success': False, 'error': 'Event not found or access denied.'}
tz = self._get_user_timezone() # The slot datetime sent by the client is already UTC (the slot
# generator emits UTC); parse it directly — do NOT re-localize, which
# would double-shift the appointment by the user's UTC offset.
try: try:
start_naive = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S') start_utc = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
start_local = tz.localize(start_naive) except (ValueError, Exception):
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
return {'success': False, 'error': 'Invalid date/time format.'} return {'success': False, 'error': 'Invalid date/time format.'}
duration = float(new_duration) if new_duration else event.duration duration = float(new_duration) if new_duration else event.duration
@@ -883,12 +882,10 @@ class PortalSchedule(CustomerPortal):
if not slot_datetime: if not slot_datetime:
return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token) return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token)
tz = self._resolve_timezone(event.user_id) # The slot datetime is already UTC (the slot generator emits UTC); parse
# directly — do NOT re-localize (that double-shifts by the tz offset).
try: try:
start_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_local = tz.localize(start_naive)
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception): except (ValueError, Exception):
return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token) return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token)
@@ -1499,12 +1496,10 @@ class PortalSchedule(CustomerPortal):
'/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug '/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug
) )
tz = self._resolve_timezone(user) # The slot datetime is already UTC (the slot generator emits UTC); parse
# directly — do NOT re-localize (that double-shifts by the tz offset).
try: try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_dt_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e: except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e) _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug) return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug)
@@ -1512,17 +1507,22 @@ class PortalSchedule(CustomerPortal):
duration = float(slot_duration) duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration) stop_dt_utc = start_dt_utc + timedelta(hours=duration)
# Find or create partner for the visitor # Find or create a contact for the visitor. SECURITY: this is an
# unauthenticated endpoint and visitor_email is attacker-controlled, so
# never reuse/attach a partner that backs a login user (staff/internal),
# and never write onto an existing contact. Reuse only a plain non-user
# contact (avoids duplicates for genuine repeat visitors).
Partner = request.env['res.partner'].sudo() Partner = request.env['res.partner'].sudo()
partner = Partner.search([('email', '=ilike', visitor_email)], limit=1) partner = Partner.search([
('email', '=ilike', visitor_email),
('user_ids', '=', False),
], limit=1)
if not partner: if not partner:
partner = Partner.create({ partner = Partner.create({
'name': visitor_name, 'name': visitor_name,
'email': visitor_email, 'email': visitor_email,
'phone': visitor_phone, 'phone': visitor_phone or False,
}) })
elif visitor_phone and not partner.phone:
partner.phone = visitor_phone
address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p] address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p]
location = ', '.join(address_parts) location = ', '.join(address_parts)

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import secrets
from odoo import api, fields, models from odoo import api, fields, models

View File

@@ -4,7 +4,7 @@ import json
import logging import logging
import time import time
import requests import requests
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -338,7 +338,17 @@ class FusionCalendarAccount(models.Model):
updated = 0 updated = 0
deleted = 0 deleted = 0
for event_data in all_events: for event_data in all_events:
result = self._process_google_event(event_data) # Per-row savepoint: one bad event must not abort the whole page
# (which would leave sync_token unadvanced and re-fail every cron).
try:
with self.env.cr.savepoint():
result = self._process_google_event(event_data)
except Exception as e:
_logger.warning(
"Skipping Google event %s on account %s: %s",
event_data.get('id'), self.id, e,
)
continue
if result == 'created': if result == 'created':
created += 1 created += 1
elif result == 'updated': elif result == 'updated':
@@ -409,7 +419,15 @@ class FusionCalendarAccount(models.Model):
stop_val = vals.get('stop') or vals.get('stop_date') stop_val = vals.get('stop') or vals.get('stop_date')
if not (start_val and stop_val and vals.get('name')): if not (start_val and stop_val and vals.get('name')):
return None return None
domain = [('name', '=', vals['name']), ('active', 'in', [True, False])] # Scope to THIS account's owner so a same-titled, same-time event that
# belongs to a DIFFERENT user is never merged in. Reuse only this
# account's own pulled events, or the user's native (sourceless) events.
domain = [
('name', '=', vals['name']),
('active', 'in', [True, False]),
('partner_ids', 'in', [self.x_fc_user_id.partner_id.id]),
('x_fc_source_account_id', 'in', [self.id, False]),
]
if vals.get('allday'): if vals.get('allday'):
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)] domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
else: else:
@@ -417,20 +435,20 @@ class FusionCalendarAccount(models.Model):
return CalendarEvent.search(domain, limit=1) return CalendarEvent.search(domain, limit=1)
def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid): def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid):
"""Create or update a link between an Odoo event and an external event. """Create or update the link for this (account, external event).
If this account already has a link to the same Odoo event, update the Branches on the table's real UNIQUE key (account, external_id) so it can
external_id rather than creating a duplicate link row. Returns the never raise an IntegrityError; if the external event is already linked,
link record. re-point it at the given Odoo event. Returns the link record.
""" """
existing = EventLink.search([ existing = EventLink.search([
('x_fc_account_id', '=', self.id), ('x_fc_account_id', '=', self.id),
('x_fc_event_id', '=', odoo_event_id), ('x_fc_external_id', '=', external_id),
], limit=1) ], limit=1)
now = fields.Datetime.now() now = fields.Datetime.now()
if existing: if existing:
existing.write({ existing.write({
'x_fc_external_id': external_id, 'x_fc_event_id': odoo_event_id,
'x_fc_universal_id': ical_uid or existing.x_fc_universal_id, 'x_fc_universal_id': ical_uid or existing.x_fc_universal_id,
'x_fc_last_synced': now, 'x_fc_last_synced': now,
}) })
@@ -481,7 +499,7 @@ class FusionCalendarAccount(models.Model):
existing_link = EventLink.search([ existing_link = EventLink.search([
('x_fc_universal_id', '=', ical_uid), ('x_fc_universal_id', '=', ical_uid),
('x_fc_universal_id', '!=', False), ('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id),
], limit=1) if ical_uid else None ], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id: if existing_link and existing_link.x_fc_event_id:
@@ -527,8 +545,8 @@ class FusionCalendarAccount(models.Model):
start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00')) start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00')) end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
# Convert to naive UTC for Odoo # Convert to naive UTC for Odoo
start_utc = start_dt.astimezone(tz=None).replace(tzinfo=None) if start_dt.tzinfo else start_dt start_utc = start_dt.astimezone(timezone.utc).replace(tzinfo=None) if start_dt.tzinfo else start_dt
end_utc = end_dt.astimezone(tz=None).replace(tzinfo=None) if end_dt.tzinfo else end_dt end_utc = end_dt.astimezone(timezone.utc).replace(tzinfo=None) if end_dt.tzinfo else end_dt
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
vals = { vals = {
@@ -567,10 +585,12 @@ class FusionCalendarAccount(models.Model):
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt, MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt,
) )
all_events = []
next_sync_token = self.x_fc_sync_token next_sync_token = self.x_fc_sync_token
page_num = 0 page_num = 0
max_events = 5000 if self.x_fc_sync_token else 2000 created = 0
updated = 0
deleted = 0
processed = 0
while url: while url:
page_num += 1 page_num += 1
@@ -594,16 +614,28 @@ class FusionCalendarAccount(models.Model):
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
# Process each page as it arrives — no unbounded accumulation and no
# event cap that would silently drop everything past the limit. Each
# event gets its own savepoint so one bad row can't abort the page.
page_events = data.get('value', []) page_events = data.get('value', [])
all_events.extend(page_events) for event_data in page_events:
_logger.warning("MS sync account %s page %d: %d events (total %d)", self.id, page_num, len(page_events), len(all_events)) try:
with self.env.cr.savepoint():
if len(all_events) >= max_events: result = self._process_microsoft_event(event_data)
_logger.warning( except Exception as e:
"MS sync account %s: hit event limit (%d/%d), stopping fetch", _logger.warning(
self.id, len(all_events), max_events, "Skipping MS event %s on account %s: %s",
) event_data.get('id'), self.id, e,
break )
continue
if result == 'created':
created += 1
elif result == 'updated':
updated += 1
elif result == 'deleted':
deleted += 1
processed += 1
_logger.warning("MS sync account %s page %d: %d events (processed %d total)", self.id, page_num, len(page_events), processed)
url = data.get('@odata.nextLink') url = data.get('@odata.nextLink')
if not url: if not url:
@@ -611,21 +643,6 @@ class FusionCalendarAccount(models.Model):
if '$deltatoken=' in delta_link: if '$deltatoken=' in delta_link:
next_sync_token = delta_link.split('$deltatoken=')[-1] next_sync_token = delta_link.split('$deltatoken=')[-1]
_logger.warning("MS sync account %s: processing %d events...", self.id, len(all_events))
created = 0
updated = 0
deleted = 0
for i, event_data in enumerate(all_events):
result = self._process_microsoft_event(event_data)
if result == 'created':
created += 1
elif result == 'updated':
updated += 1
elif result == 'deleted':
deleted += 1
if (i + 1) % 25 == 0:
_logger.warning("MS sync account %s: processed %d/%d events", self.id, i + 1, len(all_events))
self.sudo().write({ self.sudo().write({
'x_fc_sync_token': next_sync_token, 'x_fc_sync_token': next_sync_token,
'x_fc_last_sync': fields.Datetime.now(), 'x_fc_last_sync': fields.Datetime.now(),
@@ -714,7 +731,7 @@ class FusionCalendarAccount(models.Model):
existing_link = EventLink.search([ existing_link = EventLink.search([
('x_fc_universal_id', '=', ical_uid), ('x_fc_universal_id', '=', ical_uid),
('x_fc_universal_id', '!=', False), ('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id),
], limit=1) if ical_uid else None ], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id: if existing_link and existing_link.x_fc_event_id:

0
scripts/verify_service_booking.sh Executable file → Normal file
View File