Compare commits
11 Commits
claude/ser
...
claude/fus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7c028c30 | ||
|
|
41ce3784d7 | ||
|
|
98873c4e39 | ||
|
|
28e5e7f9de | ||
|
|
c71c60350b | ||
|
|
ba1e15da07 | ||
|
|
f1bf5b214c | ||
|
|
983e576fdc | ||
|
|
7cbf4f25df | ||
|
|
e35c120af8 | ||
|
|
e34892f5c0 |
@@ -3104,72 +3104,3 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
|
||||
- Add new gotchas in the right format
|
||||
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
||||
- Know the deployment fact that fusion_portal is always co-installed
|
||||
|
||||
## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`)
|
||||
|
||||
The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`),
|
||||
so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this
|
||||
wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix
|
||||
the real cause — verify with a render, not by eye):
|
||||
|
||||
1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.**
|
||||
The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under
|
||||
`.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex;
|
||||
margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`,
|
||||
`.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property
|
||||
the scoped rule doesn't set). Measured live: every wizard `.row` computed
|
||||
`display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields
|
||||
to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are
|
||||
`sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class
|
||||
that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …).
|
||||
|
||||
2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).**
|
||||
SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{
|
||||
grid-template-columns:1fr } … }` was nested high in the file, *before* the base
|
||||
`.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have
|
||||
the same specificity, so the later base rule overrode the media query — it was **dead**,
|
||||
and the inner field-grids never collapsed to one column on a phone (fields crammed 2–3
|
||||
across). `matchMedia('(max-width:560px)')` returned true while the columns stayed 2-up —
|
||||
the tell that it's a cascade-order bug, not a media-match bug. **Fix:** all responsive
|
||||
`@media` overrides live at the **end** of the `.o_service_booking { … }` block.
|
||||
|
||||
**How it was verified (do this, don't eyeball):** pull the live compiled bundle from prod
|
||||
(`env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` returns `ir.attachment`
|
||||
record(s) in Odoo 19 — read `.raw`, not a string), render the wizard markup against it with
|
||||
the real web-client height/scroll chain (`html,body{height:100%}` → `.o_web_client` flex
|
||||
column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard's
|
||||
`height:100%;overflow:auto` scrolls) at 320/390/768/1280, and read computed
|
||||
`grid-template-columns` / `margin-left` / `display`. A standalone vanilla-Bootstrap repro is
|
||||
**not** faithful — it rendered fine and falsely cleared the bug.
|
||||
|
||||
## 48. Service Booking wizard — dynamic fields (live client search + address autocomplete), v19.0.9.7.0
|
||||
|
||||
The wizard is a **client action** (registered OWL component), not a form view, so two
|
||||
"type-ahead" features had to be built into the component itself:
|
||||
|
||||
1. **Live client search** ("Existing customer" box). Endpoint
|
||||
`/fusion_claims/service_booking/search_customers` (jsonrpc, auth=user) searches
|
||||
`res.partner` and resolves a typed SO number to its partner; the JS debounces (250 ms),
|
||||
shows a `.sb-cust-results` dropdown, and `pickCustomer()` sets `state.partnerId` + fills the
|
||||
contact fields. The backend (`action_book_from_wizard`) already consumes `partner_id` for
|
||||
`cust_mode='existing'`, so picking links the existing contact.
|
||||
**GOTCHA (cost a near-miss): `res.partner` has NO `mobile` field in Odoo 19** — a domain
|
||||
leaf `('mobile','ilike',q)` raises `ValueError: Invalid field res.partner.mobile`, and
|
||||
because the controller's `except` swallows it the search silently returns nothing (looks
|
||||
exactly like "search not working"). Build the OR domain only over fields present in
|
||||
`Partner._fields` (`name`/`phone`/`email`); same for reading `p.mobile`. Verify any new
|
||||
partner-field reference against `_fields` before shipping.
|
||||
|
||||
2. **Address autocomplete.** `google_address_autocomplete.js` patches `FormController` only,
|
||||
so it does **not** reach this client action. The component loads Google Places itself
|
||||
(key from ICP `fusion_claims.google_maps_api_key`), attaches via `useRef('root')` +
|
||||
`onMounted`/`onPatched` to every `input.sb-addr-input`, and writes
|
||||
`street`/`city`/`lat`/`lng` straight into reactive `state` (no DOM hacks — we're inside the
|
||||
component). Re-attach on patch is guarded by an `_sbAc` flag per input and an `_addrStarted`
|
||||
/`_addrNoKey` gate so a missing key just degrades to manual entry and can never break render
|
||||
(both lifecycle calls are `.catch(()=>{})`).
|
||||
|
||||
**Verifying the search without a browser:** the endpoint logic is plain ORM — smoke it in an
|
||||
`odoo shell` against real data (`Partner.search(<built domain>, limit=8)`); a vanilla repro of
|
||||
the `.sb-cust-results` dropdown markup + compiled CSS confirms the UI. Address autocomplete
|
||||
needs the live key + Google Maps, so it can only be confirmed in a real browser session.
|
||||
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.9.7.0',
|
||||
'version': '19.0.9.2.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
@@ -98,13 +98,9 @@
|
||||
'data/ir_cron_data.xml',
|
||||
'data/ir_actions_server_data.xml',
|
||||
'data/product_labor_data.xml',
|
||||
'data/service_rate_products.xml',
|
||||
'data/service_rate_data.xml',
|
||||
'wizard/status_change_reason_wizard_views.xml',
|
||||
'views/res_company_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/service_rate_views.xml',
|
||||
'views/service_booking_action.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_journal_views.xml',
|
||||
@@ -185,20 +181,12 @@
|
||||
# Dashboard OWL countdown widget
|
||||
'fusion_claims/static/src/js/fc_posting_countdown.js',
|
||||
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
|
||||
# Service Booking wizard (client action): tokens MUST load before
|
||||
# the component scss so the --sb-* vars resolve.
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
'fusion_claims/static/src/js/service_booking/service_booking.js',
|
||||
'fusion_claims/static/src/xml/service_booking.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
# Dark bundle recompiles the same SCSS with the dark
|
||||
# $o-webclient-color-scheme default so tokens branch correctly.
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import service_booking
|
||||
@@ -1,76 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class ServiceBookingController(http.Controller):
|
||||
|
||||
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
|
||||
def refdata(self, **kw):
|
||||
env = request.env
|
||||
Users = env['res.users']
|
||||
techs = Users.search([('x_fc_is_field_staff', '=', True)]) \
|
||||
if 'x_fc_is_field_staff' in Users._fields else Users.search([])
|
||||
Rate = env['fusion.service.rate']
|
||||
rates = Rate.search([('rate_kind', '=', 'callout'), ('active', '=', True)])
|
||||
per_km = Rate.get_rate('per_km')
|
||||
|
||||
def labour(code):
|
||||
r = Rate.get_rate(code)
|
||||
return r.price if r else 0.0
|
||||
|
||||
return {
|
||||
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
|
||||
'callout_rates': [{
|
||||
'code': r.code, 'category': r.category, 'timing': r.timing,
|
||||
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
|
||||
} for r in rates],
|
||||
'per_km': per_km.price if per_km else 0.70,
|
||||
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
|
||||
'lift': labour('labour_lift')},
|
||||
}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/search_customers', type='jsonrpc', auth='user')
|
||||
def search_customers(self, query=None, **kw):
|
||||
"""Live customer lookup for the booking wizard's 'Existing customer' box.
|
||||
Matches res.partner by name / phone / mobile / email, and also resolves a
|
||||
typed sale-order reference to its customer. Returns up to 8 light dicts."""
|
||||
q = (query or '').strip()
|
||||
if len(q) < 2:
|
||||
return {'results': []}
|
||||
env = request.env
|
||||
Partner = env['res.partner'].sudo()
|
||||
# Build the OR domain only over fields that actually exist on this DB —
|
||||
# res.partner.mobile is NOT present in Odoo 19, so referencing it raises
|
||||
# ValueError and the whole lookup silently returns nothing.
|
||||
has_mobile = 'mobile' in Partner._fields
|
||||
search_fields = [f for f in ('name', 'phone', 'email', 'mobile')
|
||||
if f in Partner._fields]
|
||||
leaves = [(f, 'ilike', q) for f in search_fields]
|
||||
domain = leaves[:1]
|
||||
for leaf in leaves[1:]:
|
||||
domain = ['|'] + domain + [leaf]
|
||||
partners = Partner.search(domain, limit=8, order='write_date desc')
|
||||
# also resolve an SO number -> its partner (the hint promises "name or SO")
|
||||
if len(q) >= 3 and len(partners) < 8 and 'sale.order' in env:
|
||||
sos = env['sale.order'].sudo().search([('name', 'ilike', q)], limit=5)
|
||||
partners = (partners | sos.mapped('partner_id'))[:8]
|
||||
results = []
|
||||
for p in partners:
|
||||
phone = p.phone or (p.mobile if has_mobile else '') or ''
|
||||
results.append({
|
||||
'id': p.id,
|
||||
'name': p.name or '',
|
||||
'phone': phone,
|
||||
'email': p.email or '',
|
||||
'street': p.street or '',
|
||||
'city': p.city or '',
|
||||
})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||
def submit(self, payload=None, **kw):
|
||||
try:
|
||||
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<field name="sequence">12</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_normal" model="fusion.service.rate">
|
||||
<field name="name">Lift & 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"/>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_rush" model="fusion.service.rate">
|
||||
<field name="name">Lift & 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"/>
|
||||
<field name="sequence">21</field>
|
||||
</record>
|
||||
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
|
||||
<field name="name">Lift & 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"/>
|
||||
<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"/><field name="sequence">30</field>
|
||||
</record>
|
||||
<record id="rate_labour_lift" model="fusion.service.rate">
|
||||
<field name="name">Labour — Lift & 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"/><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_labour_inshop"/><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"/><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"/><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"/><field name="sequence">51</field>
|
||||
</record>
|
||||
<record id="rate_setup_stairlift" model="fusion.service.rate">
|
||||
<field name="name">Stairlift — Delivery & 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"/><field name="sequence">52</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,138 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Call-outs (unit) -->
|
||||
<record id="product_callout_standard_normal" model="product.product">
|
||||
<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.product">
|
||||
<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.product">
|
||||
<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.product">
|
||||
<field name="name">Service Call — Lift & 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.product">
|
||||
<field name="name">Service Call — Lift & 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.product">
|
||||
<field name="name">Service Call — Lift & 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.product">
|
||||
<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="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
<record id="product_labour_lift" model="product.product">
|
||||
<field name="name">Labour — Lift & 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="sale_ok" eval="True"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="product_labour_inshop" model="product.product">
|
||||
<field name="name">Labour — In-Shop</field>
|
||||
<field name="default_code">LAB-INSHOP</field>
|
||||
<field name="type">service</field>
|
||||
<field name="list_price">75.00</field>
|
||||
<field name="uom_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.product">
|
||||
<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.product">
|
||||
<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.product">
|
||||
<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.product">
|
||||
<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.product">
|
||||
<field name="name">Lift Chair — Delivery & 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.product">
|
||||
<field name="name">Hospital Bed — Delivery & 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.product">
|
||||
<field name="name">Stairlift — Delivery & 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.product">
|
||||
<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>
|
||||
@@ -26,5 +26,4 @@ from . import ai_agent_ext
|
||||
from . import dashboard
|
||||
from . import res_partner
|
||||
from . import technician_task
|
||||
from . import page11_sign_request
|
||||
from . import service_rate
|
||||
from . import page11_sign_request
|
||||
@@ -338,11 +338,6 @@ class SaleOrder(models.Model):
|
||||
help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
|
||||
)
|
||||
|
||||
x_fc_is_service_repair = fields.Boolean(
|
||||
string='Service Repair', copy=False,
|
||||
help='Auto-created from the technician service booking wizard.',
|
||||
)
|
||||
|
||||
x_fc_sale_type_locked = fields.Boolean(
|
||||
string='Sale Type Locked',
|
||||
compute='_compute_sale_type_locked',
|
||||
|
||||
@@ -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)
|
||||
@@ -9,7 +9,7 @@ features to the base fusion.technician.task model.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from markupsafe import Markup
|
||||
import logging
|
||||
|
||||
@@ -72,15 +72,6 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SERVICE BOOKING FIELDS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
x_fc_service_call_type = fields.Char(
|
||||
string='Service Call Type',
|
||||
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ONCHANGES
|
||||
# ------------------------------------------------------------------
|
||||
@@ -113,9 +104,15 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
|
||||
@api.constrains('sale_order_id', 'purchase_order_id')
|
||||
def _check_order_link(self):
|
||||
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
|
||||
# walk-in tasks may legitimately have none. No order link is required anymore.
|
||||
return
|
||||
for task in self:
|
||||
if task.x_fc_sync_source:
|
||||
continue
|
||||
if task.task_type == 'ltc_visit':
|
||||
continue
|
||||
if not task.sale_order_id and not task.purchase_order_id:
|
||||
raise ValidationError(_(
|
||||
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HOOK OVERRIDES
|
||||
@@ -398,166 +395,6 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
order.name, e,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SERVICE BOOKING HELPERS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
|
||||
"""Return a list of sale.order.line vals dicts for a service booking,
|
||||
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
|
||||
Rate = self.env['fusion.service.rate']
|
||||
lines = []
|
||||
callout = Rate.get_callout(category, timing, in_shop=in_shop)
|
||||
if not callout:
|
||||
return lines
|
||||
lines.append({
|
||||
'product_id': callout.product_id.id,
|
||||
'name': callout.name,
|
||||
'product_uom_qty': 1.0,
|
||||
'price_unit': callout.price,
|
||||
'name_is_km': False,
|
||||
})
|
||||
if callout.adds_per_km and distance_km:
|
||||
per_km = Rate.get_rate('per_km')
|
||||
if per_km:
|
||||
lines.append({
|
||||
'product_id': per_km.product_id.id,
|
||||
'name': '%s — %.1f km \xd7 2-way' % (per_km.name, distance_km),
|
||||
'product_uom_qty': round(distance_km * 2.0, 1),
|
||||
'price_unit': per_km.price,
|
||||
'name_is_km': True,
|
||||
})
|
||||
return lines
|
||||
|
||||
@api.model
|
||||
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
|
||||
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines.
|
||||
|
||||
Repair-SO identity is the x_fc_is_service_repair boolean (no crm.tag: fusion_claims
|
||||
has no crm dependency). x_fc_sale_type is intentionally left blank — a service repair
|
||||
is not one of the ADP/ODSP funder workflows, and the draft is editable afterwards.
|
||||
"""
|
||||
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
|
||||
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
|
||||
so_vals = {
|
||||
'partner_id': partner.id,
|
||||
'x_fc_is_service_repair': True,
|
||||
'order_line': order_lines,
|
||||
}
|
||||
return self.env['sale.order'].create(so_vals)
|
||||
|
||||
def _service_travel_origin(self):
|
||||
"""Return (lat, lng) of the technician's day-start location, to be used
|
||||
as the ORIGIN for the per-km travel calculation. NEVER returns the job's
|
||||
own address (that would give origin == destination == 0 km).
|
||||
|
||||
Fallback chain (all read-only — no geocoding API calls here):
|
||||
1. The technician's personal start address cached coords
|
||||
(res.users.partner_id.x_fc_start_address_lat/_lng — populated when
|
||||
the start address is saved, see fusion_tasks/models/res_partner.py).
|
||||
2. The company HQ start address cached coords, keyed off the
|
||||
``fusion_claims.technician_start_address`` ICP and cached by
|
||||
fusion_tasks under ``fusion_tasks.hq_coords:<address>``.
|
||||
3. (0.0, 0.0) — the correct graceful fallback. _calculate_travel_time
|
||||
guards on a falsy origin and simply returns False (→ no per-km line).
|
||||
|
||||
Geocoding is deliberately NOT performed here: a freshly typed new-client
|
||||
job address usually has no geocoded destination anyway, so distance is
|
||||
expected to be 0 in v1. We only avoid passing a WRONG origin.
|
||||
"""
|
||||
self.ensure_one()
|
||||
tech = self.technician_id
|
||||
if tech:
|
||||
partner = tech.partner_id
|
||||
if partner and partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
|
||||
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
|
||||
if hq_addr:
|
||||
cached = ICP.get_param('fusion_tasks.hq_coords:%s' % hq_addr, '')
|
||||
if cached and ',' in cached:
|
||||
try:
|
||||
lat_s, lng_s = cached.split(',', 1)
|
||||
return float(lat_s), float(lng_s)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 0.0, 0.0
|
||||
|
||||
@api.model
|
||||
def action_book_from_wizard(self, payload):
|
||||
"""Single entry point for the OWL booking wizard:
|
||||
resolve/create contact -> create task -> compute distance -> build SO -> link.
|
||||
Returns {'task_id', 'order_id'}."""
|
||||
Partner = self.env['res.partner']
|
||||
cust = payload.get('customer') or {}
|
||||
|
||||
# 1. contact: new -> find-or-create (match email then phone); existing -> chosen partner
|
||||
if payload.get('cust_mode') == 'new':
|
||||
partner = False
|
||||
email = (cust.get('email') or '').strip()
|
||||
phone = (cust.get('phone') or '').strip()
|
||||
if email:
|
||||
partner = Partner.search([('email', '=ilike', email)], limit=1)
|
||||
if not partner and phone:
|
||||
partner = Partner.search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = Partner.create({
|
||||
'name': cust.get('name') or 'Walk-in',
|
||||
'phone': phone or False, 'email': email or False,
|
||||
'street': cust.get('street') or False, 'city': cust.get('city') or False,
|
||||
})
|
||||
else:
|
||||
partner = Partner.browse(int(payload['partner_id'])) if payload.get('partner_id') else Partner
|
||||
|
||||
category = payload.get('category', 'standard')
|
||||
timing = payload.get('timing', 'normal')
|
||||
in_shop = bool(payload.get('in_shop'))
|
||||
|
||||
# technician_id is REQUIRED on a task
|
||||
technician_id = payload.get('technician_id')
|
||||
if not technician_id:
|
||||
raise UserError(_("Please choose a technician for this service booking."))
|
||||
technician_id = int(technician_id)
|
||||
|
||||
# 2. task
|
||||
dur = float(payload.get('duration_hr') or 1.0)
|
||||
t_start = float(payload.get('time_start') or 9.0)
|
||||
task_vals = {
|
||||
'task_type': 'repair',
|
||||
'technician_id': technician_id,
|
||||
'scheduled_date': payload.get('date'),
|
||||
'time_start': t_start,
|
||||
'time_end': t_start + dur,
|
||||
'duration_hours': dur,
|
||||
'is_in_store': in_shop,
|
||||
'x_fc_service_call_type': '%s_%s' % (category, timing),
|
||||
'description': payload.get('description') or payload.get('issue') or _('Service booking'),
|
||||
}
|
||||
if partner:
|
||||
task_vals['partner_id'] = partner.id
|
||||
task = self.create(task_vals)
|
||||
|
||||
# 3. per-km distance: only when the rate adds it AND we have a real origin + a
|
||||
# geocoded job destination. Origin is the technician's start, never the job.
|
||||
distance_km = 0.0
|
||||
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
|
||||
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
|
||||
origin_lat, origin_lng = task._service_travel_origin()
|
||||
if origin_lat and origin_lng:
|
||||
try:
|
||||
task._calculate_travel_time(origin_lat, origin_lng) # sets travel_distance_km
|
||||
distance_km = task.travel_distance_km or 0.0
|
||||
except Exception:
|
||||
distance_km = 0.0
|
||||
|
||||
# 4. draft repair SO + link back to the task
|
||||
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
|
||||
if order:
|
||||
task.sale_order_id = order.id
|
||||
return {'task_id': task.id, 'order_id': order.id if order else False}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VIEW ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -63,6 +63,4 @@ access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,mod
|
||||
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
|
||||
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
|
||||
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
|
||||
access_fusion_service_rate_admin,fusion.service.rate.admin,model_fusion_service_rate,base.group_system,1,1,1,1
|
||||
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
|
||||
|
@@ -1,208 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
import { Component, useState, onWillStart, onMounted, onPatched, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ServiceBookingWizard extends Component {
|
||||
static template = "fusion_claims.ServiceBookingWizard";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.rootRef = useRef("root");
|
||||
this.state = useState({
|
||||
custMode: "existing",
|
||||
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "", lat: 0, lng: 0 },
|
||||
partnerId: false, soSearch: "", custResults: [], custSearching: false,
|
||||
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
||||
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
||||
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
||||
description: "", materials: "",
|
||||
technicians: [], calloutRates: [], perKm: 0.70,
|
||||
labour: { onsite: 85, inshop: 75, lift: 110 }, distanceKm: 13, saving: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
const r = await rpc("/fusion_claims/service_booking/refdata", {});
|
||||
Object.assign(this.state, {
|
||||
technicians: r.technicians || [],
|
||||
calloutRates: r.callout_rates || [],
|
||||
perKm: r.per_km ?? 0.70,
|
||||
labour: r.labour || this.state.labour,
|
||||
});
|
||||
});
|
||||
// Address autocomplete attaches after the DOM exists and re-attaches when
|
||||
// OWL re-renders (e.g. switching to "New client" reveals a second address
|
||||
// input). Fully guarded — a missing Maps key just means manual entry.
|
||||
onMounted(() => { this._initAddrAutocomplete().catch(() => {}); });
|
||||
onPatched(() => { this._initAddrAutocomplete().catch(() => {}); });
|
||||
}
|
||||
|
||||
// ---- live customer search (Existing customer box) ----
|
||||
onCustSearch(ev) {
|
||||
const q = ev.target.value || "";
|
||||
this.state.soSearch = q;
|
||||
this.state.partnerId = false; // typing again unlinks any picked contact
|
||||
clearTimeout(this._custTimer);
|
||||
const term = q.trim();
|
||||
if (term.length < 2) { this.state.custResults = []; this.state.custSearching = false; return; }
|
||||
this.state.custSearching = true;
|
||||
this._custTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await rpc("/fusion_claims/service_booking/search_customers", { query: term });
|
||||
this.state.custResults = r.results || [];
|
||||
} catch (e) {
|
||||
this.state.custResults = [];
|
||||
}
|
||||
this.state.custSearching = false;
|
||||
}, 250);
|
||||
}
|
||||
pickCustomer(c) {
|
||||
this.state.partnerId = c.id;
|
||||
this.state.customer.name = c.name || "";
|
||||
this.state.customer.phone = c.phone || "";
|
||||
this.state.customer.email = c.email || "";
|
||||
this.state.customer.street = c.street || "";
|
||||
this.state.customer.city = c.city || "";
|
||||
this.state.custResults = [];
|
||||
this.state.soSearch = c.name + (c.phone ? ` · ${c.phone}` : "");
|
||||
}
|
||||
|
||||
// ---- Google Places address autocomplete (wizard-local; the FormController
|
||||
// patch in google_address_autocomplete.js does NOT reach a client action) ----
|
||||
async _getMapsKey() {
|
||||
try {
|
||||
return await this.orm.call("ir.config_parameter", "get_param", ["fusion_claims.google_maps_api_key"]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
_loadMaps(key) {
|
||||
if (window.google?.maps?.places) return Promise.resolve();
|
||||
if (window._sbMapsLoading) return window._sbMapsLoading;
|
||||
window._sbMapsLoading = new Promise((resolve, reject) => {
|
||||
window._sbMapsReady = () => resolve();
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=places&callback=_sbMapsReady`;
|
||||
s.async = true; s.defer = true; s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return window._sbMapsLoading;
|
||||
}
|
||||
async _initAddrAutocomplete() {
|
||||
const root = this.rootRef.el;
|
||||
if (!root) return;
|
||||
if (!this._addrStarted) {
|
||||
if (!root.querySelector("input.sb-addr-input")) return; // nothing to bind yet
|
||||
this._addrStarted = true;
|
||||
const key = await this._getMapsKey();
|
||||
if (!key) { this._addrNoKey = true; return; }
|
||||
try { await this._loadMaps(key); } catch (e) { return; }
|
||||
}
|
||||
if (this._addrNoKey || !window.google?.maps?.places) return;
|
||||
// re-query after the await: OWL may have swapped inputs during loading
|
||||
root.querySelectorAll("input.sb-addr-input").forEach((inp) => {
|
||||
if (inp._sbAc) return;
|
||||
inp._sbAc = true;
|
||||
try {
|
||||
const ac = new google.maps.places.Autocomplete(inp, {
|
||||
componentRestrictions: { country: "ca" },
|
||||
types: ["address"],
|
||||
fields: ["address_components", "formatted_address", "geometry"],
|
||||
});
|
||||
ac.addListener("place_changed", () => {
|
||||
const place = ac.getPlace();
|
||||
if (!place || !place.address_components) return;
|
||||
let city = "";
|
||||
for (const c of place.address_components) {
|
||||
if (c.types.includes("locality")) city = c.long_name;
|
||||
else if (c.types.includes("sublocality_level_1") && !city) city = c.long_name;
|
||||
}
|
||||
this.state.customer.street = place.formatted_address || inp.value;
|
||||
if (city) this.state.customer.city = city;
|
||||
if (place.geometry && place.geometry.location) {
|
||||
this.state.customer.lat = place.geometry.location.lat();
|
||||
this.state.customer.lng = place.geometry.location.lng();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
inp._sbAc = false; // allow a later retry if construction failed
|
||||
}
|
||||
});
|
||||
}
|
||||
get callout() {
|
||||
if (this.state.inShop) return null;
|
||||
return this.state.calloutRates.find(
|
||||
r => r.category === this.state.category && r.timing === this.state.timing) || null;
|
||||
}
|
||||
get labourRate() {
|
||||
if (this.state.inShop) return this.state.labour.inshop;
|
||||
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
|
||||
}
|
||||
get estimate() {
|
||||
const c = this.callout;
|
||||
const callout = c ? c.price : 0;
|
||||
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
|
||||
const billHr = Math.max(0, this.state.durationHr - incl);
|
||||
const labour = billHr * this.labourRate;
|
||||
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
|
||||
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
|
||||
}
|
||||
get endLabel() {
|
||||
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
|
||||
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
|
||||
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
|
||||
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
|
||||
}
|
||||
fmt(n) { return (n || 0).toFixed(2); }
|
||||
onDevice(ev) {
|
||||
this.state.device = ev.target.value;
|
||||
this.state.category = ev.target.value === "lift" ? "lift" : "standard";
|
||||
}
|
||||
onCallType(ev) {
|
||||
const r = this.state.calloutRates.find(x => x.code === ev.target.value);
|
||||
if (r) { this.state.category = r.category; this.state.timing = r.timing; }
|
||||
}
|
||||
setCust(m) { this.state.custMode = m; }
|
||||
setAmpm(v) { this.state.ampm = v; }
|
||||
|
||||
toggleInShop() { this.state.inShop = !this.state.inShop; }
|
||||
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
|
||||
|
||||
async submit() {
|
||||
if (this.state.saving) return;
|
||||
const s = this.state;
|
||||
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
|
||||
this.notification.add("Client name and phone are required.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
if (!s.technicianId) {
|
||||
this.notification.add("Please choose a technician.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
s.saving = true;
|
||||
const payload = {
|
||||
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
|
||||
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
|
||||
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
|
||||
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
|
||||
email_confirm: s.emailConfirm, google_review: s.googleReview,
|
||||
description: s.description, materials: s.materials,
|
||||
};
|
||||
try {
|
||||
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
|
||||
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
|
||||
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window", res_model: "fusion.technician.task",
|
||||
res_id: res.task_id, views: [[false, "form"]], target: "current",
|
||||
});
|
||||
} catch (e) {
|
||||
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
|
||||
s.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
|
||||
@@ -1,73 +0,0 @@
|
||||
// Fusion Claims — Service Booking wizard design tokens.
|
||||
//
|
||||
// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on
|
||||
// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into
|
||||
// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at
|
||||
// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom
|
||||
// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode /
|
||||
// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19.
|
||||
//
|
||||
// Values are copied verbatim from the mockup's :root (light) and
|
||||
// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// --- light values (mockup :root / [data-theme="light"]) ---
|
||||
$_page: #eef0f3;
|
||||
$_panel: #e6e9ed;
|
||||
$_card: #ffffff;
|
||||
$_border: #d8dadd;
|
||||
$_text: #1f2430;
|
||||
$_muted: #6b7280;
|
||||
$_faint: #9ca3af;
|
||||
$_field: #ffffff;
|
||||
$_field-border: #cfd3d8;
|
||||
$_field-focus: #3a8fb7;
|
||||
$_chip: #f1f4f7;
|
||||
$_accent: #2e7aad;
|
||||
$_accent-soft: #e8f2f8;
|
||||
$_ok: #16a34a;
|
||||
$_star: #f5b301;
|
||||
$_money: #0f7d4e;
|
||||
$_money-soft: #e7f6ee;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
// --- dark values (mockup [data-theme="dark"]) ---
|
||||
$_page: #14161b !global;
|
||||
$_panel: #1b1e24 !global;
|
||||
$_card: #22262d !global;
|
||||
$_border: #343a42 !global;
|
||||
$_text: #e7eaef !global;
|
||||
$_muted: #9aa3af !global;
|
||||
$_faint: #6b7480 !global;
|
||||
$_field: #1a1d23 !global;
|
||||
$_field-border: #3a4049 !global;
|
||||
$_field-focus: #4aa3cf !global;
|
||||
$_chip: #2a2f37 !global;
|
||||
$_accent: #3a8fb7 !global;
|
||||
$_accent-soft: #19303d !global;
|
||||
$_ok: #22c55e !global;
|
||||
$_star: #f5b301 !global;
|
||||
$_money: #34d27f !global;
|
||||
$_money-soft: #15281f !global;
|
||||
}
|
||||
|
||||
.o_service_booking {
|
||||
--sb-page: #{$_page};
|
||||
--sb-panel: #{$_panel};
|
||||
--sb-card: #{$_card};
|
||||
--sb-border: #{$_border};
|
||||
--sb-text: #{$_text};
|
||||
--sb-muted: #{$_muted};
|
||||
--sb-faint: #{$_faint};
|
||||
--sb-field: #{$_field};
|
||||
--sb-field-border: #{$_field-border};
|
||||
--sb-field-focus: #{$_field-focus};
|
||||
--sb-chip: #{$_chip};
|
||||
--sb-accent: #{$_accent};
|
||||
--sb-accent-soft: #{$_accent-soft};
|
||||
--sb-ok: #{$_ok};
|
||||
--sb-star: #{$_star};
|
||||
--sb-money: #{$_money};
|
||||
--sb-money-soft: #{$_money-soft};
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// Fusion Claims — Service Booking wizard component styles.
|
||||
//
|
||||
// Ported from the mockup (technician-booking-wizard.html) scoped under
|
||||
// .o_service_booking. The mockup's CSS custom properties (--page, --card, …)
|
||||
// are renamed mechanically to the --sb-* tokens emitted by
|
||||
// _service_booking_tokens.scss (which MUST load first in the bundle). The
|
||||
// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle.
|
||||
//
|
||||
// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card ->
|
||||
// field), never var(--bs-*). color-mix() is used only in standalone
|
||||
// background / box-shadow properties — never inside a border shorthand (the
|
||||
// Odoo 19 SCSS compiler silently drops color-mix in border shorthands).
|
||||
|
||||
.o_service_booking {
|
||||
background: var(--sb-page);
|
||||
color: var(--sb-text);
|
||||
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
// Fill the action area and scroll INTERNALLY. min-height let the root grow
|
||||
// to its content height so the (clipping) action container never scrolled;
|
||||
// height:100% caps it so overflow:auto engages on small screens.
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; }
|
||||
|
||||
.dialog {
|
||||
background: var(--sb-panel);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(16, 24, 40, .16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
|
||||
padding: 17px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
|
||||
h1 { font-size: 19px; font-weight: 700; margin: 0; }
|
||||
.sub { font-size: 12.5px; opacity: .9; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 11px 24px;
|
||||
background: var(--sb-panel);
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.step {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--sb-faint);
|
||||
padding: 5px 13px;
|
||||
border-radius: 20px;
|
||||
background: var(--sb-chip);
|
||||
}
|
||||
.step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); }
|
||||
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
||||
|
||||
.body { padding: 20px 24px 6px; }
|
||||
.sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.sb-card {
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 13px;
|
||||
padding: 16px 17px;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
}
|
||||
.sb-card.span2 { grid-column: 1 / -1; }
|
||||
.sb-card h3 {
|
||||
margin: 0 0 13px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .7px;
|
||||
text-transform: uppercase;
|
||||
color: var(--sb-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.sb-card h3 .tag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--sb-money);
|
||||
background: var(--sb-money-soft);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
|
||||
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
||||
.sb-row { margin-bottom: 12px; }
|
||||
.sb-row:last-child { margin-bottom: 0; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
||||
|
||||
input.f, select.f, textarea.f {
|
||||
width: 100%;
|
||||
background: var(--sb-field);
|
||||
color: var(--sb-text);
|
||||
border: 1px solid var(--sb-field-border);
|
||||
border-radius: 9px;
|
||||
// !important so Odoo's backend input normalisation can't strip the
|
||||
// field padding inside a client action.
|
||||
padding: 10px 12px !important;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border .15s, box-shadow .15s;
|
||||
}
|
||||
input.f:focus, select.f:focus, textarea.f:focus {
|
||||
border-color: var(--sb-field-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent);
|
||||
}
|
||||
textarea.f { resize: vertical; min-height: 56px; }
|
||||
|
||||
.hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; }
|
||||
.with-icon { position: relative; }
|
||||
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
|
||||
|
||||
// live customer-search results dropdown
|
||||
.sb-cust-search { position: relative; }
|
||||
.sb-cust-results {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 100%;
|
||||
margin-top: 4px;
|
||||
z-index: 30;
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, .16);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sb-cust-loading { padding: 10px 12px; font-size: 12.5px; color: var(--sb-faint); }
|
||||
.sb-cust-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--sb-border); }
|
||||
.sb-cust-item:last-child { border-bottom: none; }
|
||||
.sb-cust-item:hover { background: var(--sb-chip); }
|
||||
.sb-cust-name { font-size: 13.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.sb-cust-meta { font-size: 11.5px; color: var(--sb-faint); margin-top: 1px; }
|
||||
.sb-cust-linked { color: var(--sb-ok); font-weight: 600; }
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--sb-chip);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
.seg button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--sb-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); }
|
||||
.seg.full { display: flex; }
|
||||
.seg.full button { flex: 1; }
|
||||
|
||||
.timepick { display: inline-flex; align-items: stretch; gap: 7px; }
|
||||
.timepick select.f { width: auto; padding-right: 24px; }
|
||||
.ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; }
|
||||
.ampm button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--sb-muted);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ampm button.on { background: var(--sb-accent); color: #fff; }
|
||||
.endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; }
|
||||
.endtime b { color: var(--sb-text); }
|
||||
|
||||
.avail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--sb-ok);
|
||||
background: color-mix(in srgb, var(--sb-ok) 14%, transparent);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
}
|
||||
.opt:last-child { border-bottom: none; }
|
||||
.opt .lab { font-size: 13.5px; font-weight: 500; }
|
||||
.opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; }
|
||||
|
||||
.sw {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--sb-field-border);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: left .15s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
|
||||
}
|
||||
.sw.on { background: var(--sb-ok); }
|
||||
.sw.on::after { left: 21px; }
|
||||
|
||||
// fee readout inside Service & Pricing
|
||||
.feeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--sb-money-soft);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; }
|
||||
.feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); }
|
||||
|
||||
// ESTIMATE strip
|
||||
.estimate {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--sb-money-soft);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-left: 5px solid var(--sb-money);
|
||||
border-radius: 13px;
|
||||
padding: 15px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; }
|
||||
.estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); }
|
||||
.estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; }
|
||||
.estimate .total { text-align: right; }
|
||||
.estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; }
|
||||
.estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; }
|
||||
.estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; }
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 11px;
|
||||
padding: 16px 24px;
|
||||
background: var(--sb-panel);
|
||||
border-top: 1px solid var(--sb-border);
|
||||
}
|
||||
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
||||
|
||||
.sb-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.sb-btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
||||
}
|
||||
.sb-btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
.hide { display: none !important; }
|
||||
|
||||
// Responsive overrides — MUST come AFTER the base layout rules above. These
|
||||
// selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity
|
||||
// as their base rules, so the cascade only lets the media query win when it is
|
||||
// emitted later in the source. Previously this block sat right after .sb-grid
|
||||
// (BEFORE the base .two/.three/.timepick rules), so the later base rules
|
||||
// overrode it and the inner field-grids never collapsed to one column on a
|
||||
// phone — fields crammed side-by-side. Keep these last.
|
||||
@media (max-width: 780px) {
|
||||
.sb-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.wrap { margin: 12px auto; padding: 0 10px; }
|
||||
.body { padding: 14px 16px 4px; }
|
||||
.topbar { padding: 14px 16px; }
|
||||
.foot { padding: 14px 16px; flex-wrap: wrap; }
|
||||
.two, .three { grid-template-columns: 1fr; }
|
||||
.timepick { flex-wrap: wrap; }
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
|
||||
<div class="o_service_booking" t-ref="root">
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Book a Service</h1>
|
||||
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<span class="step active">Scheduled</span>
|
||||
<span class="step">En Route</span>
|
||||
<span class="step">In Progress</span>
|
||||
<span class="step">Completed</span>
|
||||
<span class="step draft">● Draft repair SO will be created</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="sb-grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="sb-row">
|
||||
<div class="seg full">
|
||||
<button t-att-class="{ on: state.custMode === 'existing' }"
|
||||
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
||||
<button t-att-class="{ on: state.custMode === 'new' }"
|
||||
t-on-click="() => this.setCust('new')">New client</button>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'existing'">
|
||||
<div class="sb-row sb-cust-search">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" t-att-value="state.soSearch" t-on-input="onCustSearch"
|
||||
placeholder="e.g. (416) 555-0142 …" autocomplete="off"/>
|
||||
<div class="hint" t-if="!state.partnerId">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
<div class="hint sb-cust-linked" t-if="state.partnerId">✓ Linked to existing contact — booking will use it.</div>
|
||||
<div class="sb-cust-results" t-if="state.custSearching or state.custResults.length">
|
||||
<div class="sb-cust-loading" t-if="state.custSearching">Searching…</div>
|
||||
<t t-else="">
|
||||
<div class="sb-cust-item" t-foreach="state.custResults" t-as="c" t-key="c.id"
|
||||
t-on-click="() => this.pickCustomer(c)">
|
||||
<div class="sb-cust-name"><t t-esc="c.name"/></div>
|
||||
<div class="sb-cust-meta"><t t-esc="c.phone"/><t t-if="c.phone and c.city"> · </t><t t-esc="c.city"/></div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'new'">
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
||||
</div>
|
||||
<div class="sb-row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||
<div class="sb-row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="sb-row three">
|
||||
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
||||
</div>
|
||||
<div class="hint">Contact is created & linked on save — all from this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="sb-row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" t-on-change="onDevice">
|
||||
<option value="standard">Mobility Scooter</option>
|
||||
<option value="standard">Powerchair</option>
|
||||
<option value="standard">Wheelchair</option>
|
||||
<option value="lift">Stairlift</option>
|
||||
<option value="lift">Patient / Ceiling Lift</option>
|
||||
<option value="standard">Lift Chair</option>
|
||||
<option value="standard">Hospital Bed</option>
|
||||
<option value="standard">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Issue / symptom</label>
|
||||
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-row" t-if="!state.inShop">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f"
|
||||
t-on-change="onCallType">
|
||||
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
|
||||
<option t-att-value="r.code"
|
||||
t-att-selected="state.category === r.category and state.timing === r.timing">
|
||||
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<div class="hint">Auto-suggested from the device — change if needed.</div>
|
||||
</div>
|
||||
<div class="feeline" t-if="!state.inShop and callout">
|
||||
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
|
||||
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
|
||||
</div>
|
||||
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" t-model.number="state.durationHr">
|
||||
<option value="0.5">30 min</option>
|
||||
<option value="1">1 hour</option>
|
||||
<option value="1.5">1.5 hours</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="3">3 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" t-model.number="state.hour">
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
<select class="f" t-model.number="state.minute">
|
||||
<option value="0">:00</option>
|
||||
<option value="15">:15</option>
|
||||
<option value="30">:30</option>
|
||||
<option value="45">:45</option>
|
||||
</select>
|
||||
<div class="ampm">
|
||||
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
|
||||
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
||||
</div>
|
||||
<div class="sb-row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f" t-model.number="state.technicianId">
|
||||
<option value="">— Choose —</option>
|
||||
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
|
||||
<option t-att-value="t.id"><t t-esc="t.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="sb-card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
||||
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
||||
</div>
|
||||
<div t-if="!state.inShop">
|
||||
<div class="sb-row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="sb-row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="sb-card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="sb-row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="sb-row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
||||
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
|
||||
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ESTIMATE -->
|
||||
<div class="estimate">
|
||||
<div class="breakdown">
|
||||
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop">—</t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
|
||||
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
|
||||
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
|
||||
</div>
|
||||
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
|
||||
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
||||
<button class="sb-btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||
<button class="sb-btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -3,5 +3,3 @@
|
||||
from . import test_signed_pages_gate
|
||||
from . import test_application_received_wizard
|
||||
from . import test_dashboard
|
||||
from . import test_service_rate
|
||||
from . import test_service_booking
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceBooking(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Task = cls.env['fusion.technician.task']
|
||||
# technician_id is required on a task (domain x_fc_is_field_staff=True).
|
||||
cls.tech = cls.env['res.users'].create({
|
||||
'name': 'Service Booking Tech',
|
||||
'login': 'svcbook_tech',
|
||||
'x_fc_is_field_staff': True,
|
||||
})
|
||||
|
||||
def test_task_without_order_is_allowed(self):
|
||||
# No SO/PO must NOT raise after the relax. description is required and a
|
||||
# non-in-store task needs an address, so set both here to isolate the test
|
||||
# to the order-link relaxation (not those unrelated base constraints).
|
||||
t = self.Task.create({
|
||||
'task_type': 'repair',
|
||||
'technician_id': self.tech.id,
|
||||
'scheduled_date': date.today() + timedelta(days=7),
|
||||
'description': 'Test repair',
|
||||
'is_in_store': True,
|
||||
})
|
||||
self.assertTrue(t.id)
|
||||
|
||||
def test_sale_order_has_service_repair_flag(self):
|
||||
so = self.env['sale.order'].new({})
|
||||
self.assertIn('x_fc_is_service_repair', so._fields)
|
||||
|
||||
def test_resolve_service_lines_standard_rush(self):
|
||||
Task = self.Task
|
||||
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
|
||||
# call-out $120 + per-km line qty 20 @ $0.70
|
||||
callout = [l for l in lines if l['price_unit'] == 120.0]
|
||||
per_km = [l for l in lines if l['name_is_km']]
|
||||
self.assertTrue(callout)
|
||||
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
|
||||
self.assertEqual(per_km[0]['price_unit'], 0.70)
|
||||
|
||||
def test_resolve_service_lines_in_shop_empty_callout(self):
|
||||
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
|
||||
self.assertEqual(lines, [])
|
||||
|
||||
def test_build_service_so(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
|
||||
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
|
||||
self.assertEqual(so.state, 'draft')
|
||||
self.assertTrue(so.x_fc_is_service_repair)
|
||||
self.assertEqual(so.partner_id, partner)
|
||||
self.assertEqual(so.order_line[0].price_unit, 95.0)
|
||||
|
||||
def test_action_book_creates_contact_task_and_so(self):
|
||||
payload = {
|
||||
'cust_mode': 'new',
|
||||
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
|
||||
'street': '88 Bloor St E', 'city': 'Toronto'},
|
||||
'category': 'standard', 'timing': 'normal', 'in_shop': False,
|
||||
'device': 'scooter', 'issue': "won't power on",
|
||||
'date': (date.today() + timedelta(days=7)).strftime('%Y-%m-%d'), 'time_start': 9.0, 'duration_hr': 1.0,
|
||||
'technician_id': self.tech.id, 'description': 'check battery',
|
||||
}
|
||||
res = self.Task.action_book_from_wizard(payload)
|
||||
self.assertTrue(res['task_id'] and res['order_id'])
|
||||
task = self.Task.browse(res['task_id'])
|
||||
self.assertEqual(task.sale_order_id.id, res['order_id'])
|
||||
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
|
||||
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
|
||||
self.assertTrue(partner)
|
||||
@@ -1,60 +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):
|
||||
# Assert against the real seed (codes are unique, so creating colliding
|
||||
# standard/normal rows would violate the UNIQUE(code) constraint).
|
||||
r = self.Rate.get_callout('standard', 'normal')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(r.code, 'callout_standard_normal')
|
||||
self.assertEqual(r.rate_kind, 'callout')
|
||||
|
||||
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):
|
||||
# 'per_km' is a seeded code; the resolver returns that row.
|
||||
r = self.Rate.get_rate('per_km')
|
||||
self.assertTrue(r)
|
||||
self.assertEqual(r.unit, 'per_km')
|
||||
|
||||
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)
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_service_booking_wizard" model="ir.actions.client">
|
||||
<field name="name">Book a Service</field>
|
||||
<field name="tag">fusion_claims.service_booking</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_service_booking"
|
||||
name="Book a Service"
|
||||
parent="fusion_tasks.menu_field_service_root"
|
||||
action="action_service_booking_wizard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
@@ -1,101 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2025 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: List View (inline-edit enabled) -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_fusion_service_rate_list" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.list</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Service Rates" editable="top"
|
||||
default_order="sequence, rate_kind, category, timing">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="rate_kind" string="Kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="unit"/>
|
||||
<field name="price" string="Rate"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
<field name="adds_per_km" string="+ km"/>
|
||||
<field name="included_labour_min" string="Incl. Labour (min)"/>
|
||||
<field name="in_shop" string="In-Shop"/>
|
||||
<field name="product_id" string="Invoice Product"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Form View -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_fusion_service_rate_form" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.form</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Service Rate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Rate name…"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identification">
|
||||
<field name="code"/>
|
||||
<field name="rate_kind" string="Kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="in_shop"/>
|
||||
<field name="active"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group string="Pricing">
|
||||
<field name="price" string="Rate"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="unit"/>
|
||||
<field name="adds_per_km"/>
|
||||
<field name="included_labour_min"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Invoice Product">
|
||||
<field name="product_id" string="Product" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Action -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="action_fusion_service_rate" model="ir.actions.act_window">
|
||||
<field name="name">Service Rates</field>
|
||||
<field name="res_model">fusion.service.rate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No service rates found.
|
||||
</p>
|
||||
<p>
|
||||
Add rates used for booking service calls, labour, travel, and delivery.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Menu item under Technician Configuration -->
|
||||
<!-- ===================================================================== -->
|
||||
<menuitem id="menu_fusion_service_rate"
|
||||
name="Service Rates"
|
||||
parent="fusion_tasks.menu_technician_config"
|
||||
action="action_fusion_service_rate"
|
||||
sequence="50"
|
||||
groups="base.group_system"/>
|
||||
|
||||
</odoo>
|
||||
BIN
fusion_plating/.DS_Store
vendored
BIN
fusion_plating/.DS_Store
vendored
Binary file not shown.
@@ -44,7 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). **EXCEPTION — do NOT add `.article` to the dpi=96 mm-based job stickers** (`fusion_plating_jobs/report/report_fp_job_sticker.xml`): those set a custom `@page` + `dpi=96` so mm maps 1:1 (rule 14), and wrapping their body in `<div class="article">` re-routes through Odoo's standard report CSS which **blows up the mm/dpi layout** — the logo + every element renders huge (tested + reverted 2026-06-04). For those labels do the OPPOSITE: leave the raw `html_container` and **strip the offending non-ASCII glyph to ASCII in the Python display helper** instead (`fp_job_sticker.py::_clean()` maps `º`/`°`/`˚`→'' , em-dash→`-`, smart-quotes→ASCII). Trade-off: you lose the literal glyph (a bake temp `375°F` prints `375F`) but the label stays clean + correctly sized. So: `.article` for normal-flow custom-header reports, ASCII-strip for the fixed-dpi mm labels. | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
|
||||
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
|
||||
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
|
||||
|
||||
@@ -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.
|
||||
@@ -1,412 +0,0 @@
|
||||
# Shop-Floor Sign-Off: Reuse Saved Plating Signature — 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:** Make shop-floor step sign-off reuse the operator's saved Plating Signature (one-tap confirm) instead of redrawing every time; capture-and-persist it the first time.
|
||||
|
||||
**Architecture:** The `/fp/workspace/load` payload exposes whether the user has a Plating Signature + the image; `job_workspace.js` shows a confirm-with-preview dialog when they do (new `FpSignatureConfirm`) and the existing `FpSignaturePad` when they don't; `/fp/workspace/sign_off` persists any drawing to `res.users.x_fc_signature_image` and drops the wasted per-step attachment.
|
||||
|
||||
**Tech Stack:** Odoo 19 (`fusion_plating_shopfloor`), OWL components, JSON-RPC controller, `HttpCase` tests.
|
||||
|
||||
---
|
||||
|
||||
## Working location (IMPORTANT — isolated worktree)
|
||||
|
||||
All work happens in the worktree **`K:\Github\Odoo-Modules-signoff-wt`** on branch **`feat/shopfloor-signoff-reuse-signature`** (off `main`). Use absolute paths under that dir for Read/Edit; for git use `git -C "K:\Github\Odoo-Modules-signoff-wt" ...` (tracked prefix `fusion_plating/`). The main checkout is in use by another session — do not touch it.
|
||||
|
||||
## Testing model
|
||||
|
||||
`fusion_plating_shopfloor` can't install on the local Community box — the `HttpCase` tests run on an Enterprise env (entech clone), like the WO-grouping deploy. Local per-task gate:
|
||||
- Python: `python -m pyflakes "<file>"` (host).
|
||||
- XML: `python -c "import xml.etree.ElementTree as ET; ET.parse(r'<file>'); print('XML OK')"`.
|
||||
- JS (ESM): `node --check` rejects `import` on a `.js`; copy to a temp `.mjs` first: `Copy-Item <file> $env:TEMP\x.mjs; node --check $env:TEMP\x.mjs` (skip if `node` absent — the asset-bundle compile during the clone-verify `-u` is the real gate).
|
||||
- SCSS: no local check; Odoo compiles it on `-u` (clone-verify catches errors).
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Module | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | shopfloor | `load` payload keys; `sign_off` persist + drop attachment. |
|
||||
| `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | shopfloor | NEW confirm dialog component. |
|
||||
| `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | shopfloor | NEW template. |
|
||||
| `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | shopfloor | NEW styling. |
|
||||
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | shopfloor | confirm-vs-draw wiring. |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | shopfloor | register 3 assets + version bump. |
|
||||
| `fusion_plating_shopfloor/tests/test_workspace_controller.py` | shopfloor | new HttpCase tests. |
|
||||
|
||||
**Build order:** backend (payload + sign_off + tests) → new component + manifest → workspace wiring → version bump + static checks → clone-verify.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — load payload + sign_off rewrite + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_shopfloor/controllers/workspace_controller.py` (load return dict ~line 241; `sign_off` ~line 450-494)
|
||||
- Test: `fusion_plating_shopfloor/tests/test_workspace_controller.py`
|
||||
|
||||
- [ ] **Step 1: Add the load payload keys.** In `workspace_controller.py`, the `load` method's `return {` dict starts with `'ok': True,` (around line 241-242). Insert these two keys immediately after the `'ok': True,` line, at the same indentation:
|
||||
|
||||
```python
|
||||
'user_has_plating_signature': bool(env.user.x_fc_signature_image),
|
||||
'user_plating_signature': (
|
||||
('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode())
|
||||
if env.user.x_fc_signature_image else ''
|
||||
),
|
||||
```
|
||||
|
||||
(`env` is already bound at the top of `load`. `x_fc_signature_image` is in `SELF_READABLE_FIELDS`, so reading `env.user`'s own value is allowed.)
|
||||
|
||||
- [ ] **Step 2: Rewrite `sign_off`.** Replace the entire `sign_off` method (the `@http.route('/fp/workspace/sign_off', ...)` decorator + method, lines ~450-494) with:
|
||||
|
||||
```python
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri=None):
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
sig = (signature_data_uri or '').strip()
|
||||
user = env.user
|
||||
if sig:
|
||||
# A drawing was supplied (first-time, or "use a different
|
||||
# signature"). Persist it as the user's Plating Signature so
|
||||
# every future sign-off + report reuses it. x_fc_signature_image
|
||||
# is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed.
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
try:
|
||||
user.write({'x_fc_signature_image': sig})
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/sign_off: persisting Plating Signature failed for uid %s",
|
||||
env.uid,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save your signature.'}
|
||||
elif not user.x_fc_signature_image:
|
||||
# No drawing AND no saved signature — nothing to sign with.
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required. Draw one to continue.',
|
||||
}
|
||||
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/sign_off: button_finish failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
|
||||
return {'ok': True, 'step_id': step.id, 'state': step.state}
|
||||
```
|
||||
|
||||
(Note: `signature_data_uri` is now optional; the per-step `ir.attachment` create is gone.)
|
||||
|
||||
- [ ] **Step 3: Write the tests.** Append to `fusion_plating_shopfloor/tests/test_workspace_controller.py` (the file already defines `_rpc`, `_TINY_PNG_B64`, and the `@tagged` decorator at the top — reuse them):
|
||||
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceSignOff(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/SIG001',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 3,
|
||||
})
|
||||
|
||||
def test_load_exposes_plating_signature_flags(self):
|
||||
self.env.user.x_fc_signature_image = False
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertFalse(res['user_has_plating_signature'])
|
||||
self.assertEqual(res['user_plating_signature'], '')
|
||||
self.env.user.x_fc_signature_image = _TINY_PNG_B64
|
||||
res2 = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertTrue(res2['user_has_plating_signature'])
|
||||
self.assertTrue(
|
||||
res2['user_plating_signature'].startswith('data:image/png;base64,'))
|
||||
|
||||
def test_sign_off_without_signature_and_no_saved_errors(self):
|
||||
self.env.user.x_fc_signature_image = False
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id, 'name': 'Final', 'sequence': 10})
|
||||
res = _rpc(self, '/fp/workspace/sign_off', step_id=step.id)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('signature', res['error'].lower())
|
||||
|
||||
def test_sign_off_with_drawing_persists_signature_and_no_attachment(self):
|
||||
self.env.user.x_fc_signature_image = False
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id, 'name': 'Final', 'sequence': 10})
|
||||
data_uri = 'data:image/png;base64,' + _TINY_PNG_B64
|
||||
# button_finish may fail on this un-started step; we assert the
|
||||
# signature-persist + no-attachment side effects, which happen first.
|
||||
_rpc(self, '/fp/workspace/sign_off',
|
||||
step_id=step.id, signature_data_uri=data_uri)
|
||||
self.env.user.invalidate_recordset(['x_fc_signature_image'])
|
||||
self.assertTrue(
|
||||
self.env.user.x_fc_signature_image,
|
||||
'drawing persisted to the Plating Signature')
|
||||
n = self.env['ir.attachment'].search_count([
|
||||
('res_model', '=', 'fp.job.step'), ('res_id', '=', step.id)])
|
||||
self.assertEqual(n, 0, 'no per-step signature attachment is created')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Static check.** Run:
|
||||
```
|
||||
python -m pyflakes "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\controllers\workspace_controller.py" "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\tests\test_workspace_controller.py"
|
||||
```
|
||||
Expected: clean (ignore pre-existing warnings on lines you didn't touch).
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py fusion_plating/fusion_plating_shopfloor/tests/test_workspace_controller.py
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): sign_off reuses+persists Plating Signature; load exposes it"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: New `FpSignatureConfirm` component + manifest registration
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js`
|
||||
- Create: `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml`
|
||||
- Create: `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss`
|
||||
- Modify: `fusion_plating_shopfloor/__manifest__.py` (assets list, after the `signature_pad.*` block ~line 81; version)
|
||||
|
||||
- [ ] **Step 1: Create the JS component.**
|
||||
|
||||
```js
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — SignatureConfirm
|
||||
//
|
||||
// Confirm dialog shown when the operator already has a saved Plating
|
||||
// Signature: previews it + "Sign & Finish" (props.onConfirm) or "Use a
|
||||
// different signature" (props.onRedraw, opens the draw-pad). No drawing here.
|
||||
// =============================================================================
|
||||
import { Component } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class FpSignatureConfirm extends Component {
|
||||
static template = "fusion_plating_shopfloor.SignatureConfirm";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function, // dialog service injects
|
||||
title: { type: String, optional: true },
|
||||
contextLabel: { type: String, optional: true },
|
||||
signatureUrl: { type: String }, // data: URI of saved sig
|
||||
onConfirm: { type: Function }, // () => commit (no drawing)
|
||||
onRedraw: { type: Function }, // () => open draw-pad
|
||||
};
|
||||
|
||||
onConfirm() {
|
||||
this.props.onConfirm();
|
||||
this.props.close();
|
||||
}
|
||||
onRedraw() {
|
||||
this.props.onRedraw();
|
||||
this.props.close();
|
||||
}
|
||||
onCancel() {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the XML template.**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
|
||||
<Dialog title="props.title or 'Confirm signature'" size="'md'">
|
||||
<div class="o_fp_sig_confirm">
|
||||
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
|
||||
<t t-esc="props.contextLabel"/>
|
||||
</div>
|
||||
<div class="o_fp_sig_preview">
|
||||
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
|
||||
</div>
|
||||
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
|
||||
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" t-on-click="onConfirm">Sign & Finish</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the SCSS.**
|
||||
|
||||
```scss
|
||||
// Confirm-with-preview dialog for shop-floor sign-off. Explicit hex per the
|
||||
// project card-styling rule (don't rely on var(--bs-border-color)).
|
||||
.o_fp_sig_confirm {
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.o_fp_sig_preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 120px;
|
||||
padding: 8px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d8dadd;
|
||||
border-radius: 4px;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 160px;
|
||||
}
|
||||
}
|
||||
.o_fp_sig_hint {
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register assets + bump version** in `__manifest__.py`. Immediately after the three `signature_pad.*` lines (the `.scss`, `.xml`, `.js` block ending ~line 81), insert:
|
||||
|
||||
```python
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js',
|
||||
```
|
||||
|
||||
And change `'version': '19.0.37.1.0',` → `'version': '19.0.37.2.0',`.
|
||||
|
||||
- [ ] **Step 5: Static checks.**
|
||||
```
|
||||
python -c "import xml.etree.ElementTree as ET; ET.parse(r'K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\static\src\xml\components\signature_confirm.xml'); print('XML OK')"
|
||||
```
|
||||
Expected: `XML OK`. (Optional JS check: copy `signature_confirm.js` to `$env:TEMP\x.mjs` and `node --check` it if `node` is present.)
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_confirm.js fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss fusion_plating/fusion_plating_shopfloor/__manifest__.py
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): FpSignatureConfirm dialog + asset registration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire confirm-vs-draw into `job_workspace.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_shopfloor/static/src/js/job_workspace.js` (import ~line 27; `static components` ~line 41; `onFinishStep` ~line 364-392)
|
||||
|
||||
- [ ] **Step 1: Import the new component.** After the existing `import { FpSignaturePad } from "./components/signature_pad";` (line 27), add:
|
||||
|
||||
```js
|
||||
import { FpSignatureConfirm } from "./components/signature_confirm";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register it in `static components`.** In the `static components = { ... };` line (~41), add `FpSignatureConfirm` to the set (e.g. right after `FpSignaturePad`):
|
||||
|
||||
```js
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `onFinishStep` and add two helpers.** Replace the whole `onFinishStep(step)` method (currently lines ~364-392, the `if (step.requires_signoff) { this.dialog.add(FpSignaturePad, {...}); return; } await this._callFinishStep(step, false);`) with:
|
||||
|
||||
```js
|
||||
async onFinishStep(step) {
|
||||
if (step.requires_signoff) {
|
||||
if (this.state.data.user_has_plating_signature) {
|
||||
// One-tap confirm with preview of the saved Plating Signature.
|
||||
this.dialog.add(FpSignatureConfirm, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
signatureUrl: this.state.data.user_plating_signature,
|
||||
onConfirm: () => this._commitSignOff(step, null), // use saved
|
||||
onRedraw: () => this._openSignaturePad(step), // draw a new one
|
||||
});
|
||||
} else {
|
||||
// First time — draw once; the backend persists it.
|
||||
this._openSignaturePad(step);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Plain finish — routes through /fp/workspace/finish_step which
|
||||
// returns structured errors so we can show the FpFinishBlockDialog.
|
||||
await this._callFinishStep(step, /* bypass */ false);
|
||||
}
|
||||
|
||||
_openSignaturePad(step) {
|
||||
this.dialog.add(FpSignaturePad, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: (dataUri) => this._commitSignOff(step, dataUri),
|
||||
});
|
||||
}
|
||||
|
||||
async _commitSignOff(step, dataUri) {
|
||||
try {
|
||||
const res = await fpRpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri, // null -> backend uses the saved signature
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step signed off and finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`fpRpc`, `this.dialog`, `this.notification`, `this.refresh`, `this._callFinishStep` all already exist in this component — verify the imports/usages are unchanged.)
|
||||
|
||||
- [ ] **Step 4: Static check (optional JS).** Copy `job_workspace.js` to `$env:TEMP\x.mjs` and `node --check $env:TEMP\x.mjs` if `node` is present; otherwise rely on the clone-verify asset compile.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
|
||||
git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): workspace sign-off confirms saved signature, draws only when absent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify on an entech clone
|
||||
|
||||
**Files:** none (verification only). Mirror the WO-grouping clone-verify recipe.
|
||||
|
||||
- [ ] **Step 1: Clone + upgrade + tests.** On entech: clone `admin` → throwaway UTF-8 DB (`createdb -O odoo -E UTF8 -T template0 --lc-collate=C --lc-ctype=C`, then `pg_dump admin | psql`), stage this branch's `fusion_plating_shopfloor` files into `/mnt/extra-addons/custom/fusion_plating_shopfloor`, then:
|
||||
```
|
||||
odoo -c /etc/odoo/odoo.conf -d <clone> -u fusion_plating_shopfloor --test-enable \
|
||||
--test-tags /fusion_plating_shopfloor:TestWorkspaceSignOff --stop-after-init \
|
||||
--workers=0 --http-port=0 --gevent-port=0 --log-level=test
|
||||
```
|
||||
Expected: exit 0; the 3 new tests pass. (Run the full `/fusion_plating_shopfloor` suite + a baseline diff if any failures appear, to confirm they're pre-existing — same technique as the WO-grouping deploy.)
|
||||
|
||||
- [ ] **Step 2: Asset compile sanity.** Confirm the `-u` compiled the backend bundle without SCSS/XML errors (no `CRITICAL`/`Failed to load` for `signature_confirm`).
|
||||
|
||||
- [ ] **Step 3: Browser smoke (clone or post-deploy).** As a tech with **no** Plating Signature: finish a `requires_signoff` step → draw-pad appears → draw → their `x_fc_signature_image` is set (query DB). Finish another sign-off step → the **confirm-with-preview** dialog appears (no pad) → Sign & Finish works. Render that job's WO Detail → the saved signature shows.
|
||||
|
||||
- [ ] **Step 4: Mark complete.** Suite green + smoke confirmed → ready to deploy `fusion_plating_shopfloor` to entech (standard recipe: backup, stage, `-u`, cache-bust, restart, gated on exit 0).
|
||||
|
||||
---
|
||||
|
||||
## Self-review (by plan author)
|
||||
|
||||
- **Spec coverage:** load payload keys (Task 1) ✓; sign_off optional URI + persist + drop attachment (Task 1) ✓; `FpSignatureConfirm` (Task 2) ✓; workspace confirm-vs-draw + "use a different signature" replaces saved (Task 3) ✓; manifest assets + version (Task 2) ✓; tablet-only scope, no model/migration ✓.
|
||||
- **Placeholder scan:** no TBD/TODO; every code step has complete code; `<clone>` in Task 4 is an explicit env parameter.
|
||||
- **Type/name consistency:** `signature_data_uri` (optional, default None) consistent across controller + JS; payload keys `user_has_plating_signature` / `user_plating_signature` consistent between controller (Task 1), workspace `this.state.data.*` (Task 3); `FpSignatureConfirm` props (`signatureUrl`, `onConfirm`, `onRedraw`) consistent between the component (Task 2) and its caller (Task 3); `_commitSignOff` / `_openSignaturePad` defined and used in Task 3.
|
||||
@@ -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.
|
||||
@@ -1,192 +0,0 @@
|
||||
# Shop-Floor Sign-Off: Reuse the Saved Plating Signature
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Module(s):** `fusion_plating_shopfloor` (frontend + controller), reads `res.users.x_fc_signature_image` (defined in `fusion_plating_jobs`)
|
||||
**Author:** Gurpreet (Nexa Systems Inc.)
|
||||
**Status:** Draft — pending user review of this spec
|
||||
|
||||
## Summary
|
||||
|
||||
On the shop-floor Job Workspace, finishing any recipe step with
|
||||
`requires_signoff=True` pops a draw-pad and makes the operator **draw a
|
||||
signature from scratch every time**. Worse, that per-step drawing is
|
||||
saved as an `ir.attachment` on the step and then **never used** — the WO
|
||||
Detail / CoC reports render the signer's **Plating Signature**
|
||||
(`res.users.x_fc_signature_image`, per CLAUDE.md rule 14b), not the step
|
||||
attachment.
|
||||
|
||||
This change makes sign-off reuse the operator's saved **Plating
|
||||
Signature**: if they have one, finishing is a one-tap confirm (preview +
|
||||
"Sign & Finish"); if they don't, they draw once and it is **persisted to
|
||||
their Plating Signature**, so every later sign-off — and every report —
|
||||
uses it without redrawing.
|
||||
|
||||
## Current behaviour (the bug)
|
||||
|
||||
- `onFinishStep` ([job_workspace.js:364](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js)) — when `step.requires_signoff`, always opens `FpSignaturePad`; on submit POSTs the drawing to `/fp/workspace/sign_off`.
|
||||
- `/fp/workspace/sign_off` ([workspace_controller.py:451](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)) — requires a non-empty `signature_data_uri`, creates a per-step `ir.attachment` from it, then calls `step.button_finish()` (which sets `signoff_user_id` via `_fp_autosign_if_required`).
|
||||
- Reports read `signer_user.x_fc_signature_image`, **not** the step attachment → the drawing is wasted.
|
||||
- `x_fc_signature_image` = `fields.Binary(string='Plating Signature', attachment=True)` on `res.users` (defined in `fusion_plating_jobs/models/res_users.py`), already in `SELF_READABLE_FIELDS` **and** `SELF_WRITEABLE_FIELDS` (fusion_plating/models/res_users.py) — so a tablet tech can read and write **their own** signature with no sudo.
|
||||
|
||||
## Locked decisions (from brainstorming, 2026-06-04)
|
||||
|
||||
| Q | Decision |
|
||||
|---|----------|
|
||||
| Finish UX when the user HAS a saved signature | **Quick confirm with preview** — small dialog showing their saved signature + "Sign & Finish", plus a "Use a different signature" link. One tap, no drawing. |
|
||||
| Finish UX when the user has NO saved signature | Existing draw-pad → on submit, **persist the drawing to their Plating Signature** + finish. |
|
||||
| "Use a different signature" | Opens the draw-pad; the new drawing **replaces** their saved Plating Signature (it is their signature) and signs this step. |
|
||||
| Per-step signature `ir.attachment` | **Dropped** — redundant (reports never read it). Audit of *who signed when* stays on `signoff_user_id` + the finish timestamp. |
|
||||
| Scope | **Tablet Job Workspace only.** The backend job-form `action_signoff` already works off `x_fc_signature_image` implicitly (no draw UI) — unchanged. |
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
- A user with a saved Plating Signature never redraws — one-tap confirm.
|
||||
- A user without one draws exactly once; it persists to their Plating Signature.
|
||||
- The signature shown on certs/WO reports is the same saved Plating Signature (already true; this guarantees it exists).
|
||||
|
||||
**Non-goals**
|
||||
- Changing the backend `action_signoff` / job-form flow.
|
||||
- Per-signoff historical signature snapshots (reports already read the *live* `x_fc_signature_image`; not changing that).
|
||||
- Touching the signoff gate logic (`requires_signoff`, `_fp_autosign_if_required`, `_fp_check_signoff_complete`) — unchanged.
|
||||
- QC-checklist or any non-workspace signature surface (none use `FpSignaturePad`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Workspace load payload — expose the saved signature
|
||||
|
||||
In the `/fp/workspace/load` payload builder (`workspace_controller.py`),
|
||||
add two keys derived from the current user (`request.env.user`, already
|
||||
the per-tech session):
|
||||
|
||||
```python
|
||||
user = request.env.user
|
||||
sig = user.x_fc_signature_image # base64 or False (SELF_READABLE)
|
||||
payload['user_has_plating_signature'] = bool(sig)
|
||||
payload['user_plating_signature'] = (
|
||||
('data:image/png;base64,%s' % sig.decode()) if sig else ''
|
||||
)
|
||||
```
|
||||
|
||||
(`x_fc_signature_image` is a small PNG; one data URI per load is fine. If
|
||||
it ever grows, switch to a `/web/image/res.users/<uid>/x_fc_signature_image`
|
||||
URL — deferred.)
|
||||
|
||||
### 2. Frontend — confirm-vs-draw in `onFinishStep`
|
||||
|
||||
`job_workspace.js`, `onFinishStep(step)` — replace the unconditional
|
||||
`FpSignaturePad` branch with:
|
||||
|
||||
```js
|
||||
if (step.requires_signoff) {
|
||||
if (this.state.data.user_has_plating_signature) {
|
||||
this.dialog.add(FpSignatureConfirm, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
signatureUrl: this.state.data.user_plating_signature,
|
||||
onConfirm: () => this._commitSignOff(step, null), // no drawing -> use saved
|
||||
onRedraw: () => this._openSignaturePad(step), // draw -> replaces saved
|
||||
});
|
||||
} else {
|
||||
this._openSignaturePad(step); // first time -> draw + persist
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this._callFinishStep(step, false); // plain finish (unchanged)
|
||||
```
|
||||
|
||||
New helpers:
|
||||
- `_openSignaturePad(step)` — opens the existing `FpSignaturePad`; its `onSubmit(dataUri)` calls `this._commitSignOff(step, dataUri)`.
|
||||
- `_commitSignOff(step, dataUri)` — POSTs `{ step_id, signature_data_uri: dataUri /* may be null */ }` to `/fp/workspace/sign_off`, handles ok/error notifications + `refresh()` (the existing logic, factored out of the current inline `onSubmit`).
|
||||
|
||||
### 3. New OWL component — `FpSignatureConfirm`
|
||||
|
||||
`fusion_plating_shopfloor/static/src/js/components/signature_confirm.js`
|
||||
(+ `signature_confirm.xml`, reuse `_signature_pad.scss` tokens or add a
|
||||
small `_signature_confirm.scss`). A `Dialog` showing:
|
||||
- the saved signature image (`<img t-att-src="props.signatureUrl"/>`),
|
||||
- the context label,
|
||||
- **Sign & Finish** → `props.onConfirm(); props.close();`
|
||||
- **Use a different signature** → `props.onRedraw(); props.close();`
|
||||
- **Cancel** → `props.close();`
|
||||
|
||||
Props: `close, title?, contextLabel?, signatureUrl, onConfirm, onRedraw`.
|
||||
Mirrors `FpSignaturePad`'s shape. Register it in `JobWorkspace.components`
|
||||
and the manifest assets.
|
||||
|
||||
### 4. Backend — `/fp/workspace/sign_off` persists, drops the attachment
|
||||
|
||||
`workspace_controller.py`, `sign_off(self, step_id, signature_data_uri=None)`:
|
||||
|
||||
```python
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
sig = (signature_data_uri or '').strip()
|
||||
user = env.user
|
||||
if sig:
|
||||
# A drawing was supplied (first-time, or "use a different signature").
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
try:
|
||||
user.write({'x_fc_signature_image': sig}) # SELF_WRITEABLE; own record
|
||||
except Exception:
|
||||
_logger.exception("sign_off: persisting Plating Signature failed for uid %s", env.uid)
|
||||
return {'ok': False, 'error': 'Failed to save your signature.'}
|
||||
elif not user.x_fc_signature_image:
|
||||
# No drawing AND no saved signature — nothing to sign with.
|
||||
return {'ok': False, 'error': 'A signature is required. Draw one to continue.'}
|
||||
|
||||
try:
|
||||
step.button_finish() # sets signoff_user_id + gates
|
||||
except Exception as exc:
|
||||
_logger.exception("sign_off: button_finish failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
return {'ok': True, 'step_id': step.id, 'state': step.state}
|
||||
```
|
||||
|
||||
- `signature_data_uri` is now **optional** (defaults `None`).
|
||||
- No `ir.attachment` is created (the dropped per-step artifact).
|
||||
- The signature persists to the user's own `x_fc_signature_image` (direct write — the field is in `SELF_WRITEABLE_FIELDS`).
|
||||
|
||||
## Files touched
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `fusion_plating_shopfloor/controllers/workspace_controller.py` | `sign_off`: optional `signature_data_uri`, persist to `x_fc_signature_image`, drop attachment; add `user_has_plating_signature` + `user_plating_signature` to the load payload. |
|
||||
| 2 | `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | NEW confirm dialog. |
|
||||
| 3 | `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | NEW template. |
|
||||
| 4 | `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | NEW (small). |
|
||||
| 5 | `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `onFinishStep` branch; `_openSignaturePad` + `_commitSignOff` helpers; register `FpSignatureConfirm`. |
|
||||
| 6 | `fusion_plating_shopfloor/__manifest__.py` | add the 3 new asset files + version bump. |
|
||||
|
||||
No model, view, ACL, or migration changes. `res.users.x_fc_signature_image` already exists with the right SELF_* access.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Has saved sig → "Sign & Finish" | No drawing sent; `button_finish()` only; report uses saved sig. |
|
||||
| No saved sig → draw | Drawing persists to `x_fc_signature_image`; future steps are one-tap. |
|
||||
| Has saved sig → "Use a different signature" → draw | New drawing **replaces** saved sig + signs. |
|
||||
| Empty draw | `FpSignaturePad.onSubmit` already no-ops without ink; backend also rejects empty+no-saved. |
|
||||
| `button_finish` raises a gate error (required inputs, predecessor, etc.) | Returned as `{ok:false, error}` and shown as a notification — the signature has already persisted (harmless; it's their signature either way). |
|
||||
| Manager/Owner with no saved sig | Same flow — draws once, persists. |
|
||||
|
||||
## Testing
|
||||
|
||||
`fusion_plating_shopfloor` can't install on local Community; verify on an
|
||||
entech clone (`-u` + odoo-shell), like the WO-grouping deploy.
|
||||
|
||||
- **Unit (controller logic, runnable where the module installs):** `sign_off` with a data URI writes `env.user.x_fc_signature_image` and finishes; `sign_off` with no URI + an existing saved sig finishes without writing; `sign_off` with no URI + no saved sig returns the "signature required" error; no `ir.attachment` is created in any path.
|
||||
- **Payload:** `/fp/workspace/load` returns `user_has_plating_signature=False` + empty `user_plating_signature` for a user with no sig, and `True` + a `data:image/png;base64,…` URI once set.
|
||||
- **Live smoke (entech clone):** a tech with no Plating Signature draws on a sign-off step → their `x_fc_signature_image` is populated; the next sign-off shows the confirm-preview (no pad); the WO Detail report renders the saved signature.
|
||||
|
||||
## Static-check note
|
||||
|
||||
`node --check` rejects ESM `import` on a `.js`; copy the OWL files to
|
||||
`/tmp/x.mjs` for a syntax check, and lxml/ET-parse the `.xml` template
|
||||
(per the project's static-check conventions).
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.10.3.0',
|
||||
'version': '19.0.10.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Migrate the single Default CoC Contact (Many2one) to the multi-contact
|
||||
Many2many.
|
||||
|
||||
The field x_fc_default_coc_contact_id (Many2one column on res_partner) was
|
||||
renamed to x_fc_default_coc_contact_ids (self-referential Many2many, rel
|
||||
table fp_default_coc_contact_rel). Odoo creates the new rel table during the
|
||||
schema-update phase but leaves the old column orphaned. Copy each partner's
|
||||
single contact into the new M2m so existing per-customer CoC routing carries
|
||||
over, then drop the dead column. Idempotent + guarded on column existence.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Old column still present (Odoo doesn't drop removed-field columns)?
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'res_partner'
|
||||
AND column_name = 'x_fc_default_coc_contact_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
return
|
||||
# New M2m rel table created by the schema update before this runs.
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'fp_default_coc_contact_rel'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_default_coc_contact_rel missing — skipping CoC contact '
|
||||
'migration (rel table not created yet).')
|
||||
return
|
||||
# Copy the single value into the M2m (skip rows already present so a
|
||||
# re-run is harmless).
|
||||
cr.execute("""
|
||||
INSERT INTO fp_default_coc_contact_rel (partner_id, contact_id)
|
||||
SELECT p.id, p.x_fc_default_coc_contact_id
|
||||
FROM res_partner p
|
||||
WHERE p.x_fc_default_coc_contact_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_default_coc_contact_rel r
|
||||
WHERE r.partner_id = p.id
|
||||
AND r.contact_id = p.x_fc_default_coc_contact_id)
|
||||
""")
|
||||
moved = cr.rowcount
|
||||
cr.execute(
|
||||
"ALTER TABLE res_partner DROP COLUMN IF EXISTS "
|
||||
"x_fc_default_coc_contact_id")
|
||||
_logger.info(
|
||||
'CoC contact migration: copied %s single Default-CoC-Contact value(s) '
|
||||
'into the new x_fc_default_coc_contact_ids M2m, dropped old column.',
|
||||
moved)
|
||||
@@ -1,52 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Migrate the cert's single Customer Contact (Many2one) to a Many2many.
|
||||
|
||||
fp.certificate.contact_partner_id (single Many2one column) was renamed to
|
||||
contact_partner_ids (Many2many -> res.partner, rel
|
||||
fp_certificate_contact_partner_rel) so a cert can carry every contact who
|
||||
receives the CoC. Odoo creates the new rel table during the schema-update
|
||||
phase but leaves the old column orphaned. Copy each cert's single contact
|
||||
into the new M2m (becomes the primary / printed addressee), then drop the
|
||||
dead column. Guarded + idempotent.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'fp_certificate'
|
||||
AND column_name = 'contact_partner_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
return
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'fp_certificate_contact_partner_rel'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_certificate_contact_partner_rel missing — skipping cert '
|
||||
'customer-contact migration (rel table not created yet).')
|
||||
return
|
||||
cr.execute("""
|
||||
INSERT INTO fp_certificate_contact_partner_rel (cert_id, partner_id)
|
||||
SELECT c.id, c.contact_partner_id
|
||||
FROM fp_certificate c
|
||||
WHERE c.contact_partner_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_certificate_contact_partner_rel r
|
||||
WHERE r.cert_id = c.id
|
||||
AND r.partner_id = c.contact_partner_id)
|
||||
""")
|
||||
moved = cr.rowcount
|
||||
cr.execute(
|
||||
"ALTER TABLE fp_certificate DROP COLUMN IF EXISTS contact_partner_id")
|
||||
_logger.info(
|
||||
'Cert customer-contact migration: copied %s single contact_partner_id '
|
||||
'value(s) into the new contact_partner_ids M2m, dropped old column.',
|
||||
moved)
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from . import fp_thickness_reading
|
||||
from . import fp_certificate
|
||||
from . import fp_certificate_part
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import fp_delivery
|
||||
|
||||
@@ -58,18 +58,11 @@ class FpCertificate(models.Model):
|
||||
string='Customer Job No.',
|
||||
help="Customer's internal job / traveler reference.",
|
||||
)
|
||||
contact_partner_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
relation='fp_certificate_contact_partner_rel',
|
||||
column1='cert_id', column2='partner_id',
|
||||
string='Customer Contact',
|
||||
contact_partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer Contact',
|
||||
domain="[('parent_id', '=', partner_id)]",
|
||||
help="Contacts at the customer who receive this certificate. "
|
||||
"Auto-filled from the customer's Default CoC Contacts when a "
|
||||
'job ships. The first is the primary (its name, email and '
|
||||
'phone print on the CoC); ALL of them are emailed when the '
|
||||
'cert is sent to the customer. (Renamed from the single '
|
||||
'contact_partner_id — see migration 19.0.10.3.0.)',
|
||||
help="Specific contact person at the customer for this certificate. "
|
||||
'Their name, email, and phone are printed on the CoC.',
|
||||
)
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||
@@ -94,6 +87,10 @@ class FpCertificate(models.Model):
|
||||
thickness_reading_ids = fields.One2many(
|
||||
'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) ----------------------
|
||||
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
|
||||
@@ -443,47 +440,6 @@ class FpCertificate(models.Model):
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
def _fp_needs_thickness_data(self):
|
||||
"""True when this cert MUST carry thickness data to be issued.
|
||||
|
||||
Single source of truth shared by action_issue (hard gate) and the
|
||||
Issue-Certs wizard (readiness hint) so the two can never drift.
|
||||
|
||||
Partner side — the ceiling: a CoC needs thickness when the customer
|
||||
is strict-thickness (aerospace / Nadcap) or opts into the
|
||||
thickness-on-CoC bundle; a thickness_report cert always needs it.
|
||||
|
||||
Recipe side — suppress-only: a recipe whose requires_thickness_report
|
||||
is False (passivation, chemical conversion, anodize seal-only — no
|
||||
plating thickness physically exists) REMOVES the requirement even
|
||||
when the customer asked. This mirrors Step 2 of
|
||||
fp.job._resolve_required_cert_types so the cert-type resolver and
|
||||
this issue-time gate agree. Without it, a passivation CoC for a
|
||||
thickness customer can never be issued (the gate demands Fischerscope
|
||||
data the process cannot produce). Field-existence guards keep this
|
||||
safe when fusion_plating_jobs / fusion_plating are at an older
|
||||
schema or not installed.
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner = self.partner_id
|
||||
needs = (
|
||||
self.certificate_type == 'thickness_report'
|
||||
or (self.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if not needs:
|
||||
return False
|
||||
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
|
||||
recipe = job.recipe_id if job else False
|
||||
if (recipe and 'requires_thickness_report' in recipe._fields
|
||||
and not recipe.requires_thickness_report):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
||||
@@ -526,15 +482,12 @@ class FpCertificate(models.Model):
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_ids
|
||||
if (not rec.contact_partner_id
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||
# Auto-fill ALL the customer's CoC contacts. The first is
|
||||
# the primary (printed on the CoC); every contact is emailed
|
||||
# when the cert is sent (action_send_to_customer).
|
||||
rec.contact_partner_ids = (
|
||||
rec.partner_id.x_fc_default_coc_contact_ids
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
)
|
||||
# Lazy-fill the signer from the LIVE company owner (Settings
|
||||
# "Certificate Owner") when no per-cert / per-spec signer was
|
||||
@@ -588,27 +541,27 @@ class FpCertificate(models.Model):
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||
# of partner.x_fc_default_coc_contact_ids when set.
|
||||
if not rec.contact_partner_ids:
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact(s), '
|
||||
'or configure Default CoC Contacts on customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — primary contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||
'contact before issuing (the cert is sent by email '
|
||||
'post-issue).'
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_ids[:1].name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
})
|
||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||
# are manual-attach only — operator uploads supplier doc /
|
||||
@@ -631,18 +584,30 @@ class FpCertificate(models.Model):
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
})
|
||||
# Thickness data requirement — _fp_needs_thickness_data is the
|
||||
# single source of truth (shared with the Issue-Certs wizard).
|
||||
# A customer needs thickness data when the cert is a
|
||||
# thickness_report, or it's a CoC and the partner is
|
||||
# strict-thickness / opts into the thickness-on-CoC bundle —
|
||||
# UNLESS the job's recipe suppresses thickness
|
||||
# (requires_thickness_report=False: passivation, chemical
|
||||
# conversion, anodize seal-only — no plating thickness exists).
|
||||
# Acceptable data: logged readings on the cert OR a Fischerscope
|
||||
# PDF on the linked QC OR a cert-local Fischerscope upload.
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
partner = rec.partner_id
|
||||
if rec._fp_needs_thickness_data():
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
@@ -1124,18 +1089,11 @@ class FpCertificate(models.Model):
|
||||
"""Open email composer with the certificate PDF attached."""
|
||||
self.ensure_one()
|
||||
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
|
||||
# Send to ALL the Customer Contacts defined on this cert (auto-filled
|
||||
# from the customer's Default CoC Contacts at job creation), falling
|
||||
# back to the company. The CoC goes to exactly the contacts shown on
|
||||
# the cert's Customer Contact field.
|
||||
recipients = self.contact_partner_ids
|
||||
partner_ids = recipients.ids or (
|
||||
[self.partner_id.id] if self.partner_id else [])
|
||||
ctx = {
|
||||
'default_model': 'fp.certificate',
|
||||
'default_res_ids': self.ids,
|
||||
'default_composition_mode': 'comment',
|
||||
'default_partner_ids': partner_ids,
|
||||
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
|
||||
}
|
||||
if self.attachment_id:
|
||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||
|
||||
@@ -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')
|
||||
@@ -128,24 +128,17 @@ class ResPartner(models.Model):
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contacts (cert addressees + email recipients) -------
|
||||
# One or more named contacts who receive this customer's CoC. The first
|
||||
# is the primary (printed on the cert + pre-fills cert.contact_partner_id
|
||||
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
|
||||
# sets the list once per customer. Falls back to manual selection at
|
||||
# action_issue time if blank. Self-referential M2m (renamed from the old
|
||||
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
|
||||
x_fc_default_coc_contact_ids = fields.Many2many(
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
'res.partner',
|
||||
relation='fp_default_coc_contact_rel',
|
||||
column1='partner_id', column2='contact_id',
|
||||
string='Default CoC Contacts',
|
||||
string='Default CoC Contact',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Contacts the Certificate of Conformance is addressed to and '
|
||||
'emailed to. The first contact is the primary (printed on the '
|
||||
'cert and pre-filled as the cert contact when a job ships); '
|
||||
'the rest are copied (CC) when the CoC is sent. Leave blank to '
|
||||
'force the manager to pick at issue time. Child contacts of '
|
||||
'this company only.',
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
)
|
||||
|
||||
@@ -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_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_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
|
||||
|
||||
|
@@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_ids': [(6, 0, [self.contact_with_email.id])],
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
@@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase):
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_ids=False)
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_ids=[(6, 0, [self.contact_no_email.id])])
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
@@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_ids=False,
|
||||
contact_partner_id=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
|
||||
@@ -101,8 +101,7 @@
|
||||
<group>
|
||||
<field name="certificate_type"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="contact_partner_ids"
|
||||
widget="many2many_tags"
|
||||
<field name="contact_partner_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="not partner_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
@@ -153,6 +152,21 @@
|
||||
invisible="trend_alert == 'ok'"/>
|
||||
</group>
|
||||
<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">
|
||||
<field name="thickness_reading_ids">
|
||||
<list editable="bottom">
|
||||
|
||||
@@ -44,17 +44,15 @@
|
||||
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contacts"/>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<p class="text-muted">
|
||||
The contacts this customer's CoC is addressed to and
|
||||
emailed to. The first is the primary (printed on the
|
||||
cert); the rest are copied (CC) when the CoC is sent.
|
||||
Pre-fills cert records when a job ships. Leave blank
|
||||
to force the manager to pick at issue time.
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_ids"
|
||||
widget="many2many_tags"
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.12.4.0',
|
||||
'version': '19.0.12.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -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)
|
||||
@@ -609,38 +609,47 @@ class FpJob(models.Model):
|
||||
matches the defensive pattern used elsewhere in this file.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# ---- Step 1 — partner + part baseline ----
|
||||
req = (
|
||||
self.part_catalog_id
|
||||
and self.part_catalog_id.certificate_requirement
|
||||
) or 'inherit'
|
||||
if req == 'inherit':
|
||||
wanted = set()
|
||||
# ---- 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:
|
||||
wanted.add('coc')
|
||||
s.add('coc')
|
||||
if p.x_fc_send_thickness_report:
|
||||
wanted.add('thickness_report')
|
||||
# Three aerospace/defence partner toggles. Field guards
|
||||
# let this module load even if fusion_plating_certificates
|
||||
# is at an older version that pre-dates the new fields.
|
||||
if ('x_fc_send_nadcap_cert' in p._fields
|
||||
and p.x_fc_send_nadcap_cert):
|
||||
wanted.add('nadcap_cert')
|
||||
if ('x_fc_send_mill_test' in p._fields
|
||||
and p.x_fc_send_mill_test):
|
||||
wanted.add('mill_test')
|
||||
if ('x_fc_send_customer_specific' in p._fields
|
||||
and p.x_fc_send_customer_specific):
|
||||
wanted.add('customer_specific')
|
||||
else:
|
||||
wanted = {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
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
|
||||
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) ----
|
||||
recipe = self.recipe_id
|
||||
if recipe:
|
||||
@@ -2379,14 +2388,7 @@ class FpJob(models.Model):
|
||||
if 'contact_name' in Delivery._fields and ship_to.name:
|
||||
vals['contact_name'] = ship_to.name
|
||||
if 'contact_phone' in Delivery._fields:
|
||||
# res.partner has no `mobile` field in this Odoo 19 build —
|
||||
# guard it so the read can't AttributeError (and still picks
|
||||
# up mobile on instances that do define it).
|
||||
vals['contact_phone'] = (
|
||||
ship_to.phone
|
||||
or (ship_to.mobile if 'mobile' in ship_to._fields else '')
|
||||
or ''
|
||||
)
|
||||
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
|
||||
# Scheduled date — operator can adjust; this just primes it
|
||||
# so they're not staring at a blank field.
|
||||
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
|
||||
@@ -2662,6 +2664,58 @@ class FpJob(models.Model):
|
||||
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):
|
||||
"""Auto-create one draft fp.certificate per type returned by
|
||||
_resolve_required_cert_types. Idempotent per type — re-running
|
||||
@@ -2678,7 +2732,7 @@ class FpJob(models.Model):
|
||||
(a per-spec override). Left empty
|
||||
otherwise so the LIVE company owner
|
||||
resolves at render / issue time.
|
||||
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
@@ -2713,11 +2767,8 @@ class FpJob(models.Model):
|
||||
signer = spec.signer_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||
# ALL the customer's CoC contacts -> the cert's Customer Contact
|
||||
# M2m. First is the primary (printed on the CoC); every contact
|
||||
# is emailed at send (fp.certificate.action_send_to_customer).
|
||||
contact = self.partner_id.x_fc_default_coc_contact_ids
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
@@ -2752,10 +2803,7 @@ class FpJob(models.Model):
|
||||
# spec_reference is what action_issue blocks on.
|
||||
# Format spec.code + revision for the cert text.
|
||||
if spec and 'spec_reference' in Cert._fields:
|
||||
ref = spec.code or ''
|
||||
if spec.revision:
|
||||
ref = (f'{ref} Rev {spec.revision}'
|
||||
if ref else f'Rev {spec.revision}')
|
||||
ref = self._fp_format_spec_ref(spec)
|
||||
if ref:
|
||||
vals['spec_reference'] = ref
|
||||
if 'customer_spec_id' in Cert._fields:
|
||||
@@ -2787,10 +2835,14 @@ class FpJob(models.Model):
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_ids' in Cert._fields and contact:
|
||||
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
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)
|
||||
self.message_post(body=Markup(_(
|
||||
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer '
|
||||
|
||||
@@ -486,45 +486,6 @@ class FpJobStep(models.Model):
|
||||
step.state = 'cancelled'
|
||||
return True
|
||||
|
||||
def button_reset(self):
|
||||
"""Reset a step back to 'ready' so it can be redone — operator
|
||||
self-serve for a mistake, an accidental skip, or a customer redo
|
||||
request. Clears the finish + sign-off stamps and closes any open
|
||||
timelog so the redo re-captures them; KEEPS the first-start audit
|
||||
(date_started / started_by) and the move / CoC history intact.
|
||||
Posts a chatter audit on the parent job. No-op on a step already
|
||||
ready/pending (nothing to undo).
|
||||
"""
|
||||
now = fields.Datetime.now()
|
||||
for step in self:
|
||||
if step.state in ('ready', 'pending'):
|
||||
continue
|
||||
prev_label = dict(
|
||||
step._fields['state'].selection
|
||||
).get(step.state, step.state)
|
||||
open_logs = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished)
|
||||
if open_logs:
|
||||
open_logs.write({'date_finished': now, 'state': 'stopped'})
|
||||
vals = {'state': 'ready'}
|
||||
if 'date_finished' in step._fields:
|
||||
vals['date_finished'] = False
|
||||
if 'finished_by_user_id' in step._fields:
|
||||
vals['finished_by_user_id'] = False
|
||||
if 'signoff_user_id' in step._fields:
|
||||
vals['signoff_user_id'] = False
|
||||
step.write(vals)
|
||||
if step.job_id:
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(s)s" reset to Ready (was %(p)s) by %(u)s '
|
||||
'for redo.'
|
||||
) % {
|
||||
's': step.name or '?',
|
||||
'p': prev_label,
|
||||
'u': self.env.user.display_name,
|
||||
})
|
||||
return True
|
||||
|
||||
def write(self, vals):
|
||||
"""Post a chatter trail on the parent JOB whenever an active
|
||||
step gets reassigned. The step itself already tracks
|
||||
@@ -885,16 +846,6 @@ class FpJobStep(models.Model):
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
if not node:
|
||||
return Prompt
|
||||
# Master switch (Sub 12d): when the recipe node opts OUT of
|
||||
# measurement collection, the Record-Inputs wizard returns ZERO
|
||||
# rows (fp_job_step_input_wizard.default_get). The finish gate MUST
|
||||
# agree — otherwise required prompts are demanded with no way to
|
||||
# enter them and the step is permanently stuck (bake nodes with
|
||||
# collect_measurements=False but required prompts — WO-30098 + 63
|
||||
# others on entech). Honour the switch here so gate <=> wizard.
|
||||
if ('collect_measurements' in node._fields
|
||||
and not node.collect_measurements):
|
||||
return Prompt
|
||||
prompts = node.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||
|
||||
@@ -18,13 +18,7 @@ def _clean(text):
|
||||
t = str(text)
|
||||
for a, b in ((u'—', '-'), (u'–', '-'), (u'‘', "'"),
|
||||
(u'’', "'"), (u'“', '"'), (u'”', '"'),
|
||||
(u'…', '...'),
|
||||
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
|
||||
# type for "375ºF", the real degree '°' (U+00B0), and the ring
|
||||
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
|
||||
# html_container path (no .article UTF-8 wrapper — and adding one
|
||||
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
|
||||
(u'º', ''), (u'°', ''), (u'˚', '')):
|
||||
(u'…', '...')):
|
||||
t = t.replace(a, b)
|
||||
return t.strip()
|
||||
|
||||
@@ -94,9 +88,6 @@ class FpJob(models.Model):
|
||||
'qty': qty,
|
||||
'due': due_s,
|
||||
'thk': thk,
|
||||
# Real thickness present (has a digit) — drives the prominent
|
||||
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
|
||||
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
|
||||
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
|
||||
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
|
||||
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
# reaches state='done'.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
@@ -66,34 +65,3 @@ class FpReceiving(models.Model):
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
|
||||
# ---- Sticker printing from the Receiving screen (2026-06-04) ----------
|
||||
# Both stickers loop the SO's boxes (one label per box). Pass a SINGLE
|
||||
# work order: the box loop is sale-order-scoped, so feeding every job
|
||||
# would reprint each box label once per job. One job → exactly one label
|
||||
# per box. Falls back to a single 1/1 label when no boxes exist yet.
|
||||
def _fp_sticker_jobs(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return self.env['fp.job']
|
||||
return self.env['fp.job'].sudo().search(
|
||||
[('sale_order_id', '=', self.sale_order_id.id)], order='id', limit=1)
|
||||
|
||||
def _fp_print_sticker(self, xmlid):
|
||||
self.ensure_one()
|
||||
jobs = self._fp_sticker_jobs()
|
||||
if not jobs:
|
||||
raise UserError(_(
|
||||
'No work order exists for this receiving yet — create the '
|
||||
'Work Order before printing stickers.'))
|
||||
return self.env.ref(xmlid).report_action(jobs)
|
||||
|
||||
def action_print_external_sticker(self):
|
||||
"""Customer (external) box sticker(s) for this receiving's WO."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker')
|
||||
|
||||
def action_print_internal_sticker(self):
|
||||
"""Shop (internal) box sticker(s) — same layout, internal notes."""
|
||||
return self._fp_print_sticker(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker_internal')
|
||||
|
||||
@@ -395,6 +395,66 @@ class SaleOrder(models.Model):
|
||||
return part.recipe_id
|
||||
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):
|
||||
"""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)
|
||||
return
|
||||
|
||||
# Group by (recipe, part, spec, thickness, serial). Lines that
|
||||
# share ALL FIVE collapse into one WO. Bundling lines with
|
||||
# different specs / thicknesses / serials under one WO would
|
||||
# carry the first line's values onto the cert + sticker —
|
||||
# silent mis-attestation. No-recipe lines still get their own
|
||||
# group each.
|
||||
# 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 = {}
|
||||
unrecipe_idx = 0
|
||||
_sig_cache = {}
|
||||
for line in plating_lines:
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
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)
|
||||
key = self._fp_line_group_key(line, sig_cache=_sig_cache)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
|
||||
# Order groups by min line sequence so dash-suffixes mirror SO
|
||||
|
||||
@@ -57,11 +57,8 @@
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<!-- NOT bound to the fp.job Print menu (removed from the list per
|
||||
request). Printed via the Receiving screen button
|
||||
(fp.receiving.action_print_internal_sticker). eval=False clears
|
||||
any binding a prior install left in the DB. -->
|
||||
<field name="binding_model_id" eval="False"/>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
@@ -87,13 +84,13 @@
|
||||
/* Layout B rail + main */
|
||||
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
|
||||
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
|
||||
.r-logo { height: 9mm; line-height: 9mm; text-align: center; }
|
||||
.r-logo img { max-height: 8mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 13mm; background: #000; color: #fff; padding: 0; }
|
||||
.r-logo { height: 11mm; line-height: 11mm; text-align: center; }
|
||||
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
|
||||
.r-wo { height: 14mm; background: #000; color: #fff; padding: 0; }
|
||||
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
|
||||
.r-qrflags { height: 32mm; text-align: center; }
|
||||
.r-qrflags { height: 36mm; text-align: center; }
|
||||
.qftbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.qftbl td { vertical-align: middle; text-align: center; }
|
||||
.qfqr { width: 66%; }
|
||||
@@ -107,23 +104,72 @@
|
||||
.qfwrap-qr img { position: absolute; width: 39mm; height: 39mm; top: -3.9mm; left: -3.9mm; }
|
||||
.qftags { width: 34%; border-left: 0.5mm solid #000; }
|
||||
.qftags .badge { display: block; width: 15mm; margin: 1.4mm auto; font-size: 9.5pt; padding: 0.8mm 0; }
|
||||
.qffull { line-height: 32mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
|
||||
/* Internal (Layout A) header QR — same ~10% quiet-zone crop. Trimmed
|
||||
30mm -> 27mm so the QR-driven black band is shorter and the freed
|
||||
height flows to the NOTES block below. Still well above scan size. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 33.75mm; height: 33.75mm; top: -3.375mm; left: -3.375mm; }
|
||||
.r-fld { padding: 0.7mm 2.2mm; }
|
||||
.qffull { line-height: 36mm; }
|
||||
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 33mm; height: 33mm; vertical-align: middle; }
|
||||
.qfwrap-full img { position: absolute; width: 41mm; height: 41mm; top: -4.1mm; left: -4.1mm; }
|
||||
/* Internal (Layout A) header QR — same ~10% quiet-zone crop, bigger box. */
|
||||
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 30mm; height: 30mm; vertical-align: middle; }
|
||||
.qfwrap-int img { position: absolute; width: 37.5mm; height: 37.5mm; top: -3.75mm; left: -3.75mm; }
|
||||
.r-fld { padding: 1mm 2.2mm; }
|
||||
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
|
||||
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
|
||||
.m-thk { padding: 1.8mm 2.6mm 2.4mm; }
|
||||
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
|
||||
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
|
||||
</style>
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal body — Layout A ===================== -->
|
||||
<template id="fp_job_internal_body">
|
||||
<div class="label-page"><div class="label">
|
||||
<table class="fpt">
|
||||
<tr style="height:22mm" class="rule">
|
||||
<td class="band pad">
|
||||
<span style="float:right"><span class="tag">INTERNAL</span></span>
|
||||
<span class="lbl" style="color:#fff">Work Order</span>
|
||||
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
|
||||
</td>
|
||||
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;vertical-align:middle;padding:1mm">
|
||||
<span class="qfwrap-int"><img t-att-src="_qr"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:12mm" class="rule">
|
||||
<td class="pad" colspan="2">
|
||||
<span class="lbl">Part#</span>
|
||||
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
|
||||
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
|
||||
<span style="float:right">
|
||||
<t t-if="d['mask']"><span class="badge">MASK</span></t>
|
||||
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="height:10mm" class="rule">
|
||||
<td style="padding:0" colspan="2"><table class="fpt"><tr>
|
||||
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
|
||||
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
|
||||
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
|
||||
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></td>
|
||||
</tr>
|
||||
<t t-if="d['bake']">
|
||||
<tr style="height:13mm" class="rule">
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
|
||||
<span class="inshead">BAKE</span>
|
||||
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
|
||||
<span class="inshead">NOTES</span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<!-- ===================== External body — Layout B ===================== -->
|
||||
<template id="fp_job_external_body">
|
||||
<div class="label-page"><div class="label">
|
||||
@@ -169,24 +215,17 @@
|
||||
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
|
||||
</tr></table></div>
|
||||
<div style="height:8.5mm"><table class="gtbl"><tr>
|
||||
<td><span class="lbl">Due</span><span style="font-size:11pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
|
||||
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
|
||||
</tr></table></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<!-- Plating thickness — the team's most-watched spec. Relocated
|
||||
out of the cramped rail to a prominent full-width banner at
|
||||
the top of the main panel. -->
|
||||
<t t-if="d['has_thk']">
|
||||
<div class="m-thk rule"><span class="inshead">PLATING THICKNESS</span>
|
||||
<div style="font-size:21pt;font-weight:900;line-height:1;margin-top:1.4mm"><t t-esc="d['thk']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="d['bake']">
|
||||
<div class="m-bake rule"><span class="inshead">BAKE</span>
|
||||
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
|
||||
</div>
|
||||
</t>
|
||||
<div class="m-notes"><span class="inshead"><t t-esc="_notes_label or 'NOTES'"/></span>
|
||||
<div class="m-notes"><span class="inshead">NOTES</span>
|
||||
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,35 +233,16 @@
|
||||
</template>
|
||||
|
||||
<!-- ===================== Internal outer (per job) ===================== -->
|
||||
<!-- Internal sticker = a COPY of the External (Layout B, one per box) but
|
||||
showing the INTERNAL description instead of the customer-facing notes,
|
||||
and a "INTERNAL NOTES" header so the shop copy can't be confused with
|
||||
the customer copy. Identical rail/QR/logo/box otherwise. -->
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['internal_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'INTERNAL NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
|
||||
<t t-if="boxes">
|
||||
<t t-foreach="boxes" t-as="box">
|
||||
<t t-set="_box_num" t-value="box.box_number"/>
|
||||
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="_box_num" t-value="1"/>
|
||||
<t t-set="_box_cnt" t-value="1"/>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
|
||||
</t>
|
||||
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=1000, height=1000)"/>
|
||||
<t t-call="fusion_plating_jobs.fp_job_internal_body"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
@@ -234,7 +254,6 @@
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-set="d" t-value="job._fp_sticker_data()"/>
|
||||
<t t-set="_note" t-value="d['customer_notes']"/>
|
||||
<t t-set="_notes_label" t-value="'NOTES'"/>
|
||||
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
|
||||
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
|
||||
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
|
||||
|
||||
@@ -142,6 +142,16 @@
|
||||
<span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/>
|
||||
<strong>S/N:</strong>
|
||||
<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) > 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>
|
||||
<strong>
|
||||
|
||||
@@ -10,3 +10,5 @@ from . import test_autopause_cron
|
||||
from . import test_post_shop_states
|
||||
from . import test_recipe_cert_suppression
|
||||
from . import test_order_ship_state
|
||||
from . import test_combined_cert_creation
|
||||
from . import test_wo_recipe_grouping
|
||||
|
||||
@@ -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')
|
||||
@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_ids': [(6, 0, [cls.contact.id])],
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_ids, self.contact)
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
'certificate_type': 'nadcap_cert',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'contact_partner_ids': [(6, 0, [self.partner.id])],
|
||||
'contact_partner_id': self.partner.id,
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'TEST PROCESS',
|
||||
'certified_by_id': self.env.user.id,
|
||||
@@ -141,48 +141,3 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
cert.with_context(
|
||||
fp_skip_cert_authority_gate=True
|
||||
).action_issue()
|
||||
|
||||
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
|
||||
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
|
||||
"""A passivation recipe (requires_thickness_report=False) must drop
|
||||
the thickness-data requirement at issue time too — even for a
|
||||
strict-thickness customer. Regression: the recipe-suppression
|
||||
feature updated _resolve_required_cert_types but NOT the
|
||||
action_issue / wizard thickness gate, so passivation CoCs could
|
||||
never be issued (gate demanded Fischerscope data the process
|
||||
cannot produce)."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.partner.x_fc_strict_thickness_required = True
|
||||
self.recipe.requires_thickness_report = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-PASSIVATION',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
# Recipe suppresses thickness -> the shared gate must NOT demand it.
|
||||
self.assertFalse(cert._fp_needs_thickness_data())
|
||||
|
||||
# ---- Test 8: normal recipe still enforces thickness (control) ----
|
||||
def test_normal_recipe_keeps_thickness_issue_gate(self):
|
||||
"""Control for Test 7: a recipe that allows thickness
|
||||
(requires_thickness_report default True) still demands thickness
|
||||
data on the CoC for a thickness customer. Guards against
|
||||
over-suppression weakening real aerospace enforcement."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
# self.recipe.requires_thickness_report stays default True.
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-NORMAL',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
self.assertTrue(cert._fp_needs_thickness_data())
|
||||
|
||||
@@ -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')
|
||||
@@ -100,8 +100,8 @@
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="part_catalog_id" string="Part"/>
|
||||
<field name="customer_spec_id" string="Specification" invisible="1"/>
|
||||
<field name="recipe_id" string="Process Recipe" readonly="1"/>
|
||||
<field name="customer_spec_id" string="Specification"/>
|
||||
<field name="recipe_id" string="Process Recipe"/>
|
||||
</xpath>
|
||||
<!-- Show qty completed alongside total so the partial-qty
|
||||
picture is visible at a glance without opening Move Log. -->
|
||||
@@ -118,7 +118,7 @@
|
||||
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
|
||||
<field name="step_ids" mode="list"
|
||||
context="{'form_view_ref': 'fusion_plating_jobs.view_fp_job_step_quick_look_form'}">
|
||||
<list editable="bottom" create="false" delete="false"
|
||||
<list editable="bottom"
|
||||
decoration-info="state in ('ready', 'in_progress')"
|
||||
decoration-success="state == 'done'"
|
||||
decoration-warning="state == 'paused'"
|
||||
@@ -162,14 +162,6 @@
|
||||
title="Finish & Next" icon="fa-check-circle"
|
||||
class="btn-link o_fp_finish_btn"
|
||||
invisible="state != 'in_progress'"/>
|
||||
<!-- Reset / redo — back to Ready so the step can be
|
||||
run again (mistake, accidental skip, customer
|
||||
redo). Clears finish + sign-off stamps; keeps the
|
||||
start audit + moves. Hidden on ready/pending. -->
|
||||
<button name="button_reset" type="object"
|
||||
title="Reset step (redo)" icon="fa-undo"
|
||||
class="btn-link text-warning"
|
||||
invisible="state in ('ready', 'pending')"/>
|
||||
|
||||
<!-- Secondary actions — small icons only. Pause is
|
||||
only relevant on a running step; Record Inputs
|
||||
|
||||
@@ -25,17 +25,6 @@
|
||||
class="btn-primary" icon="fa-cogs"
|
||||
invisible="not x_fc_show_work_order_btn"
|
||||
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
|
||||
<!-- Print box stickers for this receiving's work order — one
|
||||
label per tracked box (external = customer copy, internal
|
||||
= shop copy with internal notes). Shown once a WO exists. -->
|
||||
<button name="action_print_external_sticker"
|
||||
string="External Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the customer (external) box sticker(s) — one per box."/>
|
||||
<button name="action_print_internal_sticker"
|
||||
string="Internal Sticker" type="object" icon="fa-print"
|
||||
invisible="x_fc_fp_job_count == 0"
|
||||
help="Print the shop (internal) box sticker(s) — same layout, internal notes."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Work Order smart button on the button_box (mirrors the
|
||||
|
||||
@@ -461,18 +461,17 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required',
|
||||
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
def _compute_needs_thickness(self):
|
||||
# Delegate to fp.certificate._fp_needs_thickness_data — the single
|
||||
# source of truth shared with the action_issue hard gate — so the
|
||||
# wizard's readiness hint and the gate can never drift. Honours
|
||||
# recipe-level thickness suppression (passivation = no thickness
|
||||
# even if the customer asked).
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
ln.cert_id._fp_needs_thickness_data()
|
||||
if ln.cert_id else False
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
|
||||
@@ -3,16 +3,11 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Scheduled delivery of finished parts back to a customer.
|
||||
@@ -485,51 +480,6 @@ class FpDelivery(models.Model):
|
||||
or rec.vehicle_id.display_name
|
||||
or 'Driver'),
|
||||
)
|
||||
# Packing slip travels with the shipment — render + attach it on
|
||||
# dispatch so the driver/customer get it and it's on the record.
|
||||
self._fp_generate_packing_slip()
|
||||
|
||||
def _fp_generate_packing_slip(self, force=False):
|
||||
"""Render each delivery's packing slip and store it on
|
||||
packing_list_attachment_id so it ships with the goods.
|
||||
|
||||
Fired on dispatch (action_start_route) and as a catch-all on
|
||||
action_mark_delivered. Idempotent + best-effort: skips deliveries
|
||||
that already carry a slip (unless force=True) and never raises —
|
||||
a report glitch must not block shipping. The report action is
|
||||
resolved at runtime so this module needs no hard dependency on
|
||||
fusion_plating_reports.
|
||||
"""
|
||||
report_xmlid = (
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait'
|
||||
)
|
||||
report = self.env.ref(report_xmlid, raise_if_not_found=False)
|
||||
if not report:
|
||||
return
|
||||
for rec in self:
|
||||
if 'packing_list_attachment_id' not in rec._fields:
|
||||
return
|
||||
if rec.packing_list_attachment_id and not force:
|
||||
continue
|
||||
try:
|
||||
report_model = self.env['ir.actions.report'].sudo()
|
||||
pdf_bytes, _fmt = report_model._render_qweb_pdf(
|
||||
report_xmlid, res_ids=rec.ids)
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': _('Packing Slip - %s.pdf') % rec.display_name,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(pdf_bytes),
|
||||
'mimetype': 'application/pdf',
|
||||
'res_model': rec._name,
|
||||
'res_id': rec.id,
|
||||
})
|
||||
rec.packing_list_attachment_id = att.id
|
||||
rec.message_post(
|
||||
body=_('Packing slip generated and attached.'))
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Packing slip render failed for delivery %s: %s',
|
||||
rec.display_name, exc)
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Block "delivered" until a Proof of Delivery exists.
|
||||
@@ -561,9 +511,6 @@ class FpDelivery(models.Model):
|
||||
# Sub 8 — box-parity warning. Non-blocking; just posts to
|
||||
# chatter so the shipping supervisor sees it on the record.
|
||||
rec._fp_check_box_parity()
|
||||
# Catch-all: ensure a slip exists even if dispatch was skipped
|
||||
# (generate-if-missing — won't overwrite the dispatch-time one).
|
||||
self._fp_generate_packing_slip()
|
||||
|
||||
def _fp_check_box_parity(self):
|
||||
"""Compare this delivery's boxes-out count to the boxes-in count
|
||||
|
||||
@@ -377,7 +377,7 @@ class FpNotificationTemplate(models.Model):
|
||||
# Packing slip — gated by customer preference (default True)
|
||||
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
|
||||
att = _render_report(
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_delivery_portrait', delivery,
|
||||
'fusion_plating_reports.action_report_fp_packing_slip_portrait', delivery,
|
||||
)
|
||||
if att:
|
||||
ids.append(att)
|
||||
|
||||
@@ -74,15 +74,6 @@ class SaleOrder(models.Model):
|
||||
'expected_qty': int(total_qty),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
# Seamless flow: after a single interactive confirm, jump straight
|
||||
# to the Receive Parts screen so the dock counts parts in right away
|
||||
# (idiot-proof — no hunting for the smart button). Guarded to a
|
||||
# single order + an opt-out context flag so batch / programmatic
|
||||
# confirms (and tests) keep the native return value.
|
||||
if (len(self) == 1
|
||||
and not self.env.context.get('fp_no_receiving_redirect')
|
||||
and self.x_fc_receiving_ids):
|
||||
return self.action_view_receiving()
|
||||
return res
|
||||
|
||||
def action_view_receiving(self):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.11.36.0',
|
||||
'version': '19.0.11.35.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
@@ -25,11 +25,6 @@
|
||||
# creates a cycle. Our only fp.job touchpoint is wo_scan.py which
|
||||
# uses runtime env.get('fp.job') — safe without the manifest dep.
|
||||
'fusion_plating_logistics',
|
||||
# Needed for the packing-slip Print binding on fp.receiving
|
||||
# (binding_model_id ref). Already a transitive dep via logistics;
|
||||
# declared explicitly so the ref is robust. No cycle — receiving
|
||||
# does not depend on reports.
|
||||
'fusion_plating_receiving',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td style="width: 50%; vertical-align: top;">
|
||||
<t t-set="contact" t-value="doc.contact_partner_ids[:1] or doc.partner_id"/>
|
||||
<t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/>
|
||||
<div>
|
||||
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
|
||||
<t t-esc="contact.name or ''"/>
|
||||
@@ -295,7 +295,26 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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;">
|
||||
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
|
||||
<div><t t-esc="pid[0] or '-'"/></div>
|
||||
@@ -303,11 +322,6 @@
|
||||
<div><t t-esc="pid[2] or '-'"/></div>
|
||||
</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-esc="cust_desc or doc.process_description or ''"/>
|
||||
<t t-if="doc.spec_reference">
|
||||
|
||||
@@ -461,271 +461,4 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- LOCAL DELIVERY variant (fusion.plating.delivery) -->
|
||||
<!-- The stock.picking templates above don't fit shops that ship -->
|
||||
<!-- via fusion.plating.delivery (no picking). This variant -->
|
||||
<!-- resolves the SO + lines from the delivery's job_ref (same -->
|
||||
<!-- pattern as the BoL report) and reuses the shared styles / -->
|
||||
<!-- address / signoff bits. Items come from the SO lines (vs -->
|
||||
<!-- stock moves). -->
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<!-- Items table sourced from sale.order.line (not stock.move) -->
|
||||
<template id="fp_packing_slip_items_lines">
|
||||
<table class="bordered fp-ps-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th t-att-style="w_ordered or 'width: 8%;'">
|
||||
Ordered<span class="fp-fr">Comm.</span>
|
||||
</th>
|
||||
<th t-att-style="w_shipped or 'width: 8%;'">
|
||||
Shipped<span class="fp-fr">EXP</span>
|
||||
</th>
|
||||
<th t-att-style="w_bo or 'width: 8%;'">
|
||||
B/O<span class="fp-fr">À venir</span>
|
||||
</th>
|
||||
<th class="text-start" t-att-style="w_part or 'width: 17%;'">
|
||||
Part Number<span class="fp-fr">N° de pièce</span>
|
||||
</th>
|
||||
<th t-att-style="w_po or 'width: 11%;'">
|
||||
PO<span class="fp-fr">B/C</span>
|
||||
</th>
|
||||
<th t-att-style="w_wo or 'width: 11%;'">
|
||||
WO<span class="fp-fr">B/T</span>
|
||||
</th>
|
||||
<th t-att-style="w_process or 'width: 14%;'">
|
||||
Process<span class="fp-fr">Procédé</span>
|
||||
</th>
|
||||
<th class="text-start" t-att-style="w_desc or 'width: 23%;'">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="lines" t-as="line">
|
||||
<t t-set="ordered_qty" t-value="line.product_uom_qty or 0.0"/>
|
||||
<t t-set="wo_job" t-value="line.env['fp.job'].sudo().search([('sale_order_line_ids', 'in', line.ids)], limit=1)"/>
|
||||
<t t-set="done_qty" t-value="(wo_job.qty_done if wo_job and wo_job.qty_done else ordered_qty)"/>
|
||||
<t t-set="bo_qty" t-value="ordered_qty - done_qty if ordered_qty > done_qty else 0.0"/>
|
||||
<t t-set="proc_variant" t-value="(line.x_fc_process_variant_id if 'x_fc_process_variant_id' in line._fields else False)"/>
|
||||
<t t-set="proc_label" t-value="(proc_variant.variant_label or proc_variant.name) if proc_variant else ((line.x_fc_part_catalog_id.default_process_id.variant_label or line.x_fc_part_catalog_id.default_process_id.name) if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id else '')"/>
|
||||
<tr>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(ordered_qty) if ordered_qty == int(ordered_qty) else ordered_qty"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(done_qty) if done_qty == int(done_qty) else done_qty"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="int(bo_qty) if bo_qty == int(bo_qty) else bo_qty"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_part_number"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="po_number or '-'"/>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<t t-if="wo_job"><span t-esc="wo_job.name"/></t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
<td class="fp-ps-num">
|
||||
<span t-esc="proc_label or '-'"/>
|
||||
</td>
|
||||
<td>
|
||||
<t t-call="fusion_plating_reports.customer_line_description"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template id="report_fp_packing_slip_delivery_portrait">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_styles"/>
|
||||
|
||||
<!-- Model-agnostic resolution. This ONE template backs
|
||||
four report actions (sale.order, fp.job,
|
||||
fp.receiving, fusion.plating.delivery) so the
|
||||
packing slip prints from any of those screens.
|
||||
Resolve the sale order + ship-to per doc type, then
|
||||
render a common layout from the SO lines. (Template
|
||||
id kept as "...delivery..." for back-compat with the
|
||||
action + Python that reference it.) -->
|
||||
<t t-set="m" t-value="doc._name"/>
|
||||
<t t-set="_so" t-value="False"/>
|
||||
<t t-if="m == 'sale.order'">
|
||||
<t t-set="_so" t-value="doc"/>
|
||||
</t>
|
||||
<t t-elif="m == 'fp.job' or m == 'fp.receiving'">
|
||||
<t t-set="_so" t-value="doc.sale_order_id"/>
|
||||
</t>
|
||||
<t t-elif="m == 'fusion.plating.delivery'">
|
||||
<t t-set="_dlv_job" t-value="env['fp.job'].sudo().search([('name', '=', doc.job_ref)], limit=1) if doc.job_ref else env['fp.job']"/>
|
||||
<t t-set="_so" t-value="_dlv_job.sale_order_id if _dlv_job else False"/>
|
||||
</t>
|
||||
<t t-set="_lines" t-value="_so.order_line.filtered(lambda l: l.product_id and not l.display_type and l.product_uom_qty > 0) if _so else False"/>
|
||||
|
||||
<t t-set="ship_partner" t-value="(doc.delivery_address_id or doc.partner_id) if m == 'fusion.plating.delivery' else ((_so.partner_shipping_id or _so.partner_id) if _so else doc.partner_id)"/>
|
||||
<t t-set="bill_partner" t-value="(_so.partner_invoice_id if _so and _so.partner_invoice_id else (ship_partner.commercial_partner_id or ship_partner))"/>
|
||||
<t t-set="has_carrier" t-value="m == 'fusion.plating.delivery' and 'x_fc_carrier_id' in doc._fields and doc.x_fc_carrier_id"/>
|
||||
<t t-set="ship_via" t-value="(doc.x_fc_carrier_id.name if has_carrier else (_so.x_fc_ship_via if _so and 'x_fc_ship_via' in _so._fields and _so.x_fc_ship_via else 'CUSTOMER PICKUP'))"/>
|
||||
<t t-set="tracking_text" t-value="'Ready for pick up' if not has_carrier else '—'"/>
|
||||
<t t-set="po_number" t-value="(_so.client_order_ref if _so and _so.client_order_ref else '')"/>
|
||||
<t t-set="_scheduled" t-value="doc.scheduled_date if m == 'fusion.plating.delivery' else (doc.commitment_date if m == 'sale.order' else (_so.commitment_date if _so else False))"/>
|
||||
<t t-set="_notes" t-value="doc.notes if m == 'fusion.plating.delivery' else (doc.note if m == 'sale.order' else False)"/>
|
||||
|
||||
<t t-set="so_name_raw" t-value="_so.name if _so else (doc.name or '')"/>
|
||||
<t t-set="ps_number" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
|
||||
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
|
||||
<t t-set="qr_payload" t-value="doc.name or ''"/>
|
||||
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
|
||||
|
||||
<div class="fp-report fp-ps">
|
||||
<div class="page">
|
||||
<div class="fp-ps-titlebar">
|
||||
<t t-if="ps_barcode_uri">
|
||||
<div class="fp-ps-barcode">
|
||||
<div class="fp-bc-wrap">
|
||||
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
|
||||
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span class="fp-ps-title-en">
|
||||
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
|
||||
</span>
|
||||
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
|
||||
</div>
|
||||
|
||||
<table class="bordered fp-ps-addrtable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Bill To:'"/>
|
||||
<t t-set="partner" t-value="bill_partner"/>
|
||||
</t>
|
||||
</td>
|
||||
<td style="width: 50%;">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_addr_block">
|
||||
<t t-set="label" t-value="'Ship To:'"/>
|
||||
<t t-set="partner" t-value="ship_partner"/>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="bordered fp-ps-info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 33%;">
|
||||
Ship Via<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Mode d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 33%;">
|
||||
Shipping Date<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">Date d'expédition</span>
|
||||
</th>
|
||||
<th style="width: 34%;">
|
||||
Tracking #<span class="fp-fr" style="display:block; font-weight:normal; color:#555; font-size:8pt;">N° de suivi</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span t-esc="ship_via"/></td>
|
||||
<td>
|
||||
<t t-if="_scheduled">
|
||||
<span t-out="_scheduled" t-options="{'widget': 'date'}"/>
|
||||
</t>
|
||||
<t t-else="">—</t>
|
||||
</td>
|
||||
<td><span t-esc="tracking_text"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<t t-if="_lines">
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_items_lines">
|
||||
<t t-set="lines" t-value="_lines"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p style="margin-top: 10px; color: #555;">
|
||||
No order lines found for this document
|
||||
(<span t-esc="doc.name or ''"/>).
|
||||
</p>
|
||||
</t>
|
||||
|
||||
<t t-if="_notes">
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Notes:</strong>
|
||||
<div t-out="_notes"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-call="fusion_plating_reports.fp_packing_slip_signoff"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<record id="action_report_fp_packing_slip_delivery_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">fusion.plating.delivery</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_logistics.model_fusion_plating_delivery"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<!-- Same packing slip, exposed in the Print menu of the Sale Order,
|
||||
Work Order (fp.job) and Receiving/Shipping (fp.receiving) screens.
|
||||
All reuse the model-agnostic template above. -->
|
||||
<record id="action_report_fp_packing_slip_so_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_packing_slip_job_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_packing_slip_receiving_portrait" model="ir.actions.report">
|
||||
<field name="name">Packing Slip</field>
|
||||
<field name="model">fp.receiving</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_delivery_portrait</field>
|
||||
<field name="print_report_name">'Packing Slip - %s' % (object.name or '')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating_receiving.model_fp_receiving"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.37.2.0',
|
||||
'version': '19.0.37.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||
'description': """
|
||||
@@ -79,10 +79,6 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
|
||||
# Confirm-with-preview dialog (reuse saved Plating Signature on sign-off)
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
|
||||
|
||||
@@ -240,11 +240,6 @@ class FpWorkspaceController(http.Controller):
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'user_has_plating_signature': bool(env.user.x_fc_signature_image),
|
||||
'user_plating_signature': (
|
||||
('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode())
|
||||
if env.user.x_fc_signature_image else ''
|
||||
),
|
||||
'job': {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
@@ -453,35 +448,37 @@ class FpWorkspaceController(http.Controller):
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
|
||||
def sign_off(self, step_id, signature_data_uri=None):
|
||||
def sign_off(self, step_id, signature_data_uri):
|
||||
env = request.env
|
||||
sig = (signature_data_uri or '').strip()
|
||||
if not sig:
|
||||
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required to finish this step.',
|
||||
}
|
||||
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': f'Step {step_id} not found'}
|
||||
|
||||
sig = (signature_data_uri or '').strip()
|
||||
user = env.user
|
||||
if sig:
|
||||
# A drawing was supplied (first-time, or "use a different
|
||||
# signature"). Persist it as the user's Plating Signature so
|
||||
# every future sign-off + report reuses it. x_fc_signature_image
|
||||
# is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed.
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
try:
|
||||
user.write({'x_fc_signature_image': sig})
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/sign_off: persisting Plating Signature failed for uid %s",
|
||||
env.uid,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save your signature.'}
|
||||
elif not user.x_fc_signature_image:
|
||||
# No drawing AND no saved signature — nothing to sign with.
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'A signature is required. Draw one to continue.',
|
||||
}
|
||||
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it)
|
||||
if ',' in sig and sig.startswith('data:'):
|
||||
sig = sig.split(',', 1)[1]
|
||||
|
||||
try:
|
||||
env['ir.attachment'].create({
|
||||
'name': f'signature_{step.id}.png',
|
||||
'datas': sig,
|
||||
'res_model': 'fp.job.step',
|
||||
'res_id': step.id,
|
||||
'mimetype': 'image/png',
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"workspace/sign_off: attachment failed for step %s", step.id,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save signature.'}
|
||||
|
||||
try:
|
||||
step.button_finish()
|
||||
@@ -490,7 +487,11 @@ class FpWorkspaceController(http.Controller):
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
|
||||
return {'ok': True, 'step_id': step.id, 'state': step.state}
|
||||
return {
|
||||
'ok': True,
|
||||
'step_id': step.id,
|
||||
'state': step.state,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — SignatureConfirm
|
||||
//
|
||||
// Confirm dialog shown when the operator already has a saved Plating
|
||||
// Signature: previews it + "Sign & Finish" (props.onConfirm) or "Use a
|
||||
// different signature" (props.onRedraw, opens the draw-pad). No drawing here.
|
||||
// =============================================================================
|
||||
import { Component } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class FpSignatureConfirm extends Component {
|
||||
static template = "fusion_plating_shopfloor.SignatureConfirm";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function, // dialog service injects
|
||||
title: { type: String, optional: true },
|
||||
contextLabel: { type: String, optional: true },
|
||||
signatureUrl: { type: String }, // data: URI of saved sig
|
||||
onConfirm: { type: Function }, // () => commit (no drawing)
|
||||
onRedraw: { type: Function }, // () => open draw-pad
|
||||
};
|
||||
|
||||
onConfirm() {
|
||||
this.props.onConfirm();
|
||||
this.props.close();
|
||||
}
|
||||
onRedraw() {
|
||||
this.props.onRedraw();
|
||||
this.props.close();
|
||||
}
|
||||
onCancel() {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import { useService } from "@web/core/utils/hooks";
|
||||
import { WorkflowChip } from "./components/workflow_chip";
|
||||
import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpSignatureConfirm } from "./components/signature_confirm";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
@@ -39,7 +38,7 @@ import { FileModel } from "@web/core/file_viewer/file_model";
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -364,20 +363,26 @@ export class FpJobWorkspace extends Component {
|
||||
|
||||
async onFinishStep(step) {
|
||||
if (step.requires_signoff) {
|
||||
if (this.state.data.user_has_plating_signature) {
|
||||
// One-tap confirm with a preview of the saved Plating Signature.
|
||||
this.dialog.add(FpSignatureConfirm, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
signatureUrl: this.state.data.user_plating_signature,
|
||||
onConfirm: () => this._commitSignOff(step, null), // use saved sig
|
||||
onRedraw: () => this._openSignaturePad(step), // draw a new one
|
||||
});
|
||||
} else {
|
||||
// First time — draw once; the backend persists it to the
|
||||
// user's Plating Signature so later sign-offs are one-tap.
|
||||
this._openSignaturePad(step);
|
||||
}
|
||||
this.dialog.add(FpSignaturePad, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: async (dataUri) => {
|
||||
try {
|
||||
const res = await fpRpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step signed off and finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Plain finish — route through /fp/workspace/finish_step which
|
||||
@@ -386,31 +391,6 @@ export class FpJobWorkspace extends Component {
|
||||
await this._callFinishStep(step, /* bypass */ false);
|
||||
}
|
||||
|
||||
_openSignaturePad(step) {
|
||||
this.dialog.add(FpSignaturePad, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: (dataUri) => this._commitSignOff(step, dataUri),
|
||||
});
|
||||
}
|
||||
|
||||
async _commitSignOff(step, dataUri) {
|
||||
try {
|
||||
const res = await fpRpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri, // null -> backend uses the saved signature
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step signed off and finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async _callFinishStep(step, bypassRequiredInputs) {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/finish_step", {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Confirm-with-preview dialog for shop-floor sign-off. Explicit hex per the
|
||||
// project card-styling rule (don't rely on var(--bs-border-color)).
|
||||
.o_fp_sig_confirm {
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.o_fp_sig_preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 120px;
|
||||
padding: 8px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d8dadd;
|
||||
border-radius: 4px;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 160px;
|
||||
}
|
||||
}
|
||||
.o_fp_sig_hint {
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
|
||||
<Dialog title="props.title or 'Confirm signature'" size="'md'">
|
||||
<div class="o_fp_sig_confirm">
|
||||
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
|
||||
<t t-esc="props.contextLabel"/>
|
||||
</div>
|
||||
<div class="o_fp_sig_preview">
|
||||
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
|
||||
</div>
|
||||
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
|
||||
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" t-on-click="onConfirm">Sign & Finish</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -110,10 +110,6 @@ class TestWorkspaceSignOff(HttpCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
# The HTTP request runs as the authenticated "admin" (base.user_admin);
|
||||
# the controller reads/writes THAT user's x_fc_signature_image, so the
|
||||
# test must set/read it on the same user (NOT self.env.user / uid 1).
|
||||
self.admin = self.env.ref('base.user_admin')
|
||||
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
@@ -122,24 +118,14 @@ class TestWorkspaceSignOff(HttpCase):
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
# button_finish requires a recipe link (S21 gate). A minimal step node
|
||||
# (no inputs, no sign-off) makes the gates pass so the step can finish.
|
||||
kind = self.env['fp.step.kind'].search([], limit=1)
|
||||
node_vals = {'name': 'ENP Plate', 'node_type': 'step'}
|
||||
if kind:
|
||||
node_vals['kind_id'] = kind.id
|
||||
self.node = self.env['fusion.plating.process.node'].create(node_vals)
|
||||
self.step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'ENP Plate',
|
||||
'sequence': 50,
|
||||
'state': 'in_progress',
|
||||
'recipe_node_id': self.node.id,
|
||||
})
|
||||
|
||||
def test_sign_off_rejects_empty_signature(self):
|
||||
# Empty drawing AND no saved Plating Signature -> reject.
|
||||
self.admin.x_fc_signature_image = False
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/sign_off',
|
||||
step_id=self.step.id, signature_data_uri='',
|
||||
@@ -156,46 +142,6 @@ class TestWorkspaceSignOff(HttpCase):
|
||||
self.step.invalidate_recordset(['state'])
|
||||
self.assertEqual(self.step.state, 'done')
|
||||
|
||||
def test_load_exposes_plating_signature_flags(self):
|
||||
self.admin.x_fc_signature_image = False
|
||||
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertFalse(res['user_has_plating_signature'])
|
||||
self.assertEqual(res['user_plating_signature'], '')
|
||||
self.admin.x_fc_signature_image = _TINY_PNG_B64
|
||||
res2 = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
|
||||
self.assertTrue(res2['user_has_plating_signature'])
|
||||
self.assertTrue(
|
||||
res2['user_plating_signature'].startswith('data:image/png;base64,'))
|
||||
|
||||
def test_sign_off_with_drawing_persists_signature_and_drops_attachment(self):
|
||||
# First-time draw: persists to the admin's Plating Signature, finishes
|
||||
# the (in_progress) step, and creates NO per-step signature attachment.
|
||||
self.admin.x_fc_signature_image = False
|
||||
data_uri = 'data:image/png;base64,' + _TINY_PNG_B64
|
||||
res = _rpc(
|
||||
self, '/fp/workspace/sign_off',
|
||||
step_id=self.step.id, signature_data_uri=data_uri,
|
||||
)
|
||||
self.assertTrue(res['ok'])
|
||||
self.step.invalidate_recordset(['state'])
|
||||
self.assertEqual(self.step.state, 'done')
|
||||
self.admin.invalidate_recordset(['x_fc_signature_image'])
|
||||
self.assertTrue(
|
||||
self.admin.x_fc_signature_image,
|
||||
'drawing persisted to the Plating Signature')
|
||||
n = self.env['ir.attachment'].search_count([
|
||||
('res_model', '=', 'fp.job.step'), ('res_id', '=', self.step.id)])
|
||||
self.assertEqual(n, 0, 'no per-step signature attachment is created')
|
||||
|
||||
def test_sign_off_uses_saved_signature_without_drawing(self):
|
||||
# Admin already has a saved signature -> finishing without a drawing
|
||||
# still works (no signature_data_uri sent).
|
||||
self.admin.x_fc_signature_image = _TINY_PNG_B64
|
||||
res = _rpc(self, '/fp/workspace/sign_off', step_id=self.step.id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.step.invalidate_recordset(['state'])
|
||||
self.assertEqual(self.step.state, 'done')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestWorkspaceAdvanceMilestone(HttpCase):
|
||||
|
||||
799
fusion_schedule/CLAUDE.md
Normal file
799
fusion_schedule/CLAUDE.md
Normal 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 | Mon–Sun 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` (`:801–803`)
|
||||
- ❌ `public_book_submit` (`:1505–1507`)
|
||||
- ❌ `public_manage_reschedule` (`:889–891`)
|
||||
|
||||
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 4–5 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 (8–10) 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
386
fusion_schedule/CODE_MAP.md
Normal 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 19–23,
|
||||
Microsoft endpoints 26–34.
|
||||
|
||||
**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.
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
@@ -796,12 +795,12 @@ class PortalSchedule(CustomerPortal):
|
||||
if not event.exists() or partner not in event.partner_ids:
|
||||
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:
|
||||
start_naive = datetime.strptime(new_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) as e:
|
||||
start_utc = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, Exception):
|
||||
return {'success': False, 'error': 'Invalid date/time format.'}
|
||||
|
||||
duration = float(new_duration) if new_duration else event.duration
|
||||
@@ -883,12 +882,10 @@ class PortalSchedule(CustomerPortal):
|
||||
if not slot_datetime:
|
||||
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:
|
||||
start_naive = 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)
|
||||
start_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, Exception):
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
start_dt_naive = 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)
|
||||
start_dt_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, Exception) as e:
|
||||
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
|
||||
return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug)
|
||||
@@ -1512,17 +1507,22 @@ class PortalSchedule(CustomerPortal):
|
||||
duration = float(slot_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 = Partner.search([('email', '=ilike', visitor_email)], limit=1)
|
||||
partner = Partner.search([
|
||||
('email', '=ilike', visitor_email),
|
||||
('user_ids', '=', False),
|
||||
], limit=1)
|
||||
if not partner:
|
||||
partner = Partner.create({
|
||||
'name': visitor_name,
|
||||
'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]
|
||||
location = ', '.join(address_parts)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
@@ -338,7 +338,17 @@ class FusionCalendarAccount(models.Model):
|
||||
updated = 0
|
||||
deleted = 0
|
||||
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':
|
||||
created += 1
|
||||
elif result == 'updated':
|
||||
@@ -409,7 +419,15 @@ class FusionCalendarAccount(models.Model):
|
||||
stop_val = vals.get('stop') or vals.get('stop_date')
|
||||
if not (start_val and stop_val and vals.get('name')):
|
||||
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'):
|
||||
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
|
||||
else:
|
||||
@@ -417,20 +435,20 @@ class FusionCalendarAccount(models.Model):
|
||||
return CalendarEvent.search(domain, limit=1)
|
||||
|
||||
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
|
||||
external_id rather than creating a duplicate link row. Returns the
|
||||
link record.
|
||||
Branches on the table's real UNIQUE key (account, external_id) so it can
|
||||
never raise an IntegrityError; if the external event is already linked,
|
||||
re-point it at the given Odoo event. Returns the link record.
|
||||
"""
|
||||
existing = EventLink.search([
|
||||
('x_fc_account_id', '=', self.id),
|
||||
('x_fc_event_id', '=', odoo_event_id),
|
||||
('x_fc_external_id', '=', external_id),
|
||||
], limit=1)
|
||||
now = fields.Datetime.now()
|
||||
if existing:
|
||||
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_last_synced': now,
|
||||
})
|
||||
@@ -481,7 +499,7 @@ class FusionCalendarAccount(models.Model):
|
||||
|
||||
existing_link = EventLink.search([
|
||||
('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
|
||||
|
||||
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'))
|
||||
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
|
||||
# Convert to naive UTC for Odoo
|
||||
start_utc = start_dt.astimezone(tz=None).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
|
||||
start_utc = start_dt.astimezone(timezone.utc).replace(tzinfo=None) if start_dt.tzinfo else start_dt
|
||||
end_utc = end_dt.astimezone(timezone.utc).replace(tzinfo=None) if end_dt.tzinfo else end_dt
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
vals = {
|
||||
@@ -567,10 +585,12 @@ class FusionCalendarAccount(models.Model):
|
||||
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt,
|
||||
)
|
||||
|
||||
all_events = []
|
||||
next_sync_token = self.x_fc_sync_token
|
||||
page_num = 0
|
||||
max_events = 5000 if self.x_fc_sync_token else 2000
|
||||
created = 0
|
||||
updated = 0
|
||||
deleted = 0
|
||||
processed = 0
|
||||
|
||||
while url:
|
||||
page_num += 1
|
||||
@@ -594,16 +614,28 @@ class FusionCalendarAccount(models.Model):
|
||||
resp.raise_for_status()
|
||||
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', [])
|
||||
all_events.extend(page_events)
|
||||
_logger.warning("MS sync account %s page %d: %d events (total %d)", self.id, page_num, len(page_events), len(all_events))
|
||||
|
||||
if len(all_events) >= max_events:
|
||||
_logger.warning(
|
||||
"MS sync account %s: hit event limit (%d/%d), stopping fetch",
|
||||
self.id, len(all_events), max_events,
|
||||
)
|
||||
break
|
||||
for event_data in page_events:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result = self._process_microsoft_event(event_data)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Skipping MS event %s on account %s: %s",
|
||||
event_data.get('id'), self.id, e,
|
||||
)
|
||||
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')
|
||||
if not url:
|
||||
@@ -611,21 +643,6 @@ class FusionCalendarAccount(models.Model):
|
||||
if '$deltatoken=' in delta_link:
|
||||
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({
|
||||
'x_fc_sync_token': next_sync_token,
|
||||
'x_fc_last_sync': fields.Datetime.now(),
|
||||
@@ -714,7 +731,7 @@ class FusionCalendarAccount(models.Model):
|
||||
|
||||
existing_link = EventLink.search([
|
||||
('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
|
||||
|
||||
if existing_link and existing_link.x_fc_event_id:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fusion Shipping",
|
||||
"version": "19.0.1.7.0",
|
||||
"version": "19.0.1.5.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
||||
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
||||
|
||||
@@ -431,11 +431,9 @@ class UPSRequest:
|
||||
(ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or
|
||||
(ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR')
|
||||
):
|
||||
# reference_number is already a list of {'Code', 'Value'} dicts
|
||||
# (see _ups_rest_prepare_shipping_data). UPS expects ReferenceNumber
|
||||
# to be such an object (or an array of them) with a string Value --
|
||||
# NOT {'Value': [<dict>, ...]}, which UPS rejects on the ship call.
|
||||
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = shipment_info.get('reference_number')
|
||||
request['ShipmentRequest']['Shipment']['ReferenceNumber'] = {
|
||||
'Value': shipment_info.get('reference_number')
|
||||
}
|
||||
|
||||
# Shipments from US to CA or PR require extra info
|
||||
if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']:
|
||||
|
||||
@@ -242,12 +242,9 @@ class DeliveryCarrier(models.Model):
|
||||
('EPL', 'EPL'),
|
||||
('SPL', 'SPL')],
|
||||
string="UPS Label File Type", default='GIF')
|
||||
ups_bill_my_account = fields.Boolean(string="Bill recipient's UPS account",
|
||||
help="When the customer has a UPS account number on file (set on the "
|
||||
"customer's contact, Sales & Purchase tab), charge UPS shipping to "
|
||||
"THEIR account instead of yours (UPS 'Bill Receiver'). The shipping "
|
||||
"line on the order is set to $0 since the customer pays. Customers "
|
||||
"with no account on file are billed to your account at the normal rate.")
|
||||
ups_bill_my_account = fields.Boolean(string='Bill My Account',
|
||||
help="If checked, ecommerce users will be prompted their UPS account number\n"
|
||||
"and delivery fees will be charged on it.")
|
||||
ups_saturday_delivery = fields.Boolean(string='UPS Saturday Delivery',
|
||||
help='This value added service will allow you to ship the package on saturday also.')
|
||||
ups_cod_funds_code = fields.Selection(selection=[
|
||||
@@ -264,16 +261,6 @@ class DeliveryCarrier(models.Model):
|
||||
ups_access_token = fields.Char(string='UPS Access Token', groups="base.group_system")
|
||||
ups_default_packaging_id = fields.Many2one('stock.package.type', string='UPS Package Type')
|
||||
ups_require_signature = fields.Boolean("Require Signature")
|
||||
ups_rest_documentation_type = fields.Selection(
|
||||
[('none', 'No'),
|
||||
('invoice', 'UPS commercial invoice (paperless if account enrolled)')],
|
||||
string='Generate customs invoice', default='invoice',
|
||||
help="For international shipments, have UPS generate the commercial invoice "
|
||||
"from the order data (UPS 'International Forms') and attach the PDF to the "
|
||||
"delivery. If your UPS account is enrolled in Paperless Invoice, UPS also "
|
||||
"submits it electronically to customs/the recipient (and you avoid UPS's "
|
||||
"paper-invoice surcharge). Requires an HS code and country of origin on the "
|
||||
"shipped products. No invoice is generated for domestic shipments.")
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# FEDEX SOAP (Legacy) FIELDS
|
||||
@@ -1905,7 +1892,7 @@ class DeliveryCarrier(models.Model):
|
||||
currency_code = picking.sale_id.currency_id.name
|
||||
|
||||
shipment_info = {
|
||||
'require_invoice': (self.ups_rest_documentation_type != 'none') and picking._should_generate_commercial_invoice(),
|
||||
'require_invoice': picking._should_generate_commercial_invoice(),
|
||||
'invoice_date': fields.Date.today().strftime('%Y%m%d'),
|
||||
'description': picking.origin or picking.name,
|
||||
'total_qty': sum(sml.quantity for sml in picking.move_line_ids),
|
||||
|
||||
@@ -7,8 +7,5 @@ class ResPartner(models.Model):
|
||||
property_ups_carrier_account = fields.Char(
|
||||
string="UPS Account Number",
|
||||
company_dependent=True,
|
||||
help="The customer's own UPS account number. Stored on the customer and "
|
||||
"recalled automatically when you ship to them with a UPS carrier that "
|
||||
"has \"Bill recipient's UPS account\" enabled -- UPS then bills this "
|
||||
"account for the shipping (UPS 'Bill Receiver') instead of yours.",
|
||||
help="UPS carrier account number for bill-my-account shipping.",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from odoo import models, fields, api, _
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
@@ -8,20 +8,6 @@ class StockPicking(models.Model):
|
||||
string='Shipments',
|
||||
compute='_compute_fusion_shipment_count',
|
||||
)
|
||||
fusion_shipping_label_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Shipping Label',
|
||||
compute='_compute_fusion_shipping_docs',
|
||||
help='Most recent shipping label generated for this delivery '
|
||||
'(any carrier).',
|
||||
)
|
||||
fusion_commercial_invoice_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Commercial Invoice',
|
||||
compute='_compute_fusion_shipping_docs',
|
||||
help='Most recent commercial (customs) invoice generated for this '
|
||||
'delivery (any carrier).',
|
||||
)
|
||||
|
||||
def _compute_fusion_shipment_count(self):
|
||||
Shipment = self.env['fusion.shipment']
|
||||
@@ -30,68 +16,6 @@ class StockPicking(models.Model):
|
||||
[('picking_id', '=', picking.id)]
|
||||
)
|
||||
|
||||
@api.depends('message_ids.attachment_ids')
|
||||
def _compute_fusion_shipping_docs(self):
|
||||
"""Find the latest shipping label + commercial invoice that the
|
||||
carrier posted to this delivery's chatter, so they can be opened
|
||||
from a smart button.
|
||||
|
||||
Naming is carrier-agnostic (see the carriers' send_shipping):
|
||||
- labels contain 'Label' (Label-, LabelUPS, LabelShipping-...)
|
||||
- invoices contain 'CommercialInvoice' (UPS/Canada Post) or start
|
||||
with 'ShippingDoc-' (FedEx/DHL, via _get_delivery_doc_prefix).
|
||||
"""
|
||||
Attachment = self.env['ir.attachment']
|
||||
for picking in self:
|
||||
invoice = label = Attachment
|
||||
if isinstance(picking.id, int):
|
||||
atts = Attachment.search(
|
||||
[('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', picking.id)],
|
||||
order='id desc')
|
||||
for att in atts:
|
||||
name = att.name or ''
|
||||
norm = name.lower().replace(' ', '').replace('-', '')
|
||||
if 'commercialinvoice' in norm or name.startswith('ShippingDoc'):
|
||||
invoice = invoice or att
|
||||
elif 'label' in name.lower() and 'return' not in name.lower():
|
||||
label = label or att
|
||||
if invoice and label:
|
||||
break
|
||||
picking.fusion_commercial_invoice_attachment_id = invoice.id
|
||||
picking.fusion_shipping_label_attachment_id = label.id
|
||||
|
||||
def _fusion_open_attachment(self, attachment):
|
||||
"""Open a shipping document for the operator.
|
||||
|
||||
Routes PDFs through ir.attachment.action_fusion_preview (preview
|
||||
dialog) when fusion_pdf_preview is installed, else opens the file
|
||||
in a new tab. Mirrors fusion.shipment._action_open_attachment.
|
||||
See CLAUDE.md "PDF Preview".
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not attachment:
|
||||
return False
|
||||
if hasattr(attachment, 'action_fusion_preview'):
|
||||
return attachment.action_fusion_preview(
|
||||
title=attachment.name or _('Shipping Document'),
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=false' % attachment.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_fusion_shipping_label(self):
|
||||
return self._fusion_open_attachment(
|
||||
self.fusion_shipping_label_attachment_id)
|
||||
|
||||
def action_view_fusion_commercial_invoice(self):
|
||||
return self._fusion_open_attachment(
|
||||
self.fusion_commercial_invoice_attachment_id)
|
||||
|
||||
def action_view_fusion_shipments(self):
|
||||
self.ensure_one()
|
||||
shipments = self.env['fusion.shipment'].search(
|
||||
|
||||
@@ -120,8 +120,6 @@
|
||||
<field name="ups_require_signature"/>
|
||||
<field name="ups_duty_payment" string="Duties paid by"
|
||||
required="delivery_type == 'fusion_ups_rest'"/>
|
||||
<field name="ups_rest_documentation_type"
|
||||
required="delivery_type == 'fusion_ups_rest'"/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@@ -6,28 +6,12 @@
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<field name="fusion_shipping_label_attachment_id" invisible="1"/>
|
||||
<field name="fusion_commercial_invoice_attachment_id" invisible="1"/>
|
||||
<button name="action_view_fusion_shipments" type="object"
|
||||
class="oe_stat_button" icon="fa-plane"
|
||||
invisible="fusion_shipment_count == 0">
|
||||
<field name="fusion_shipment_count" widget="statinfo"
|
||||
string="Shipments"/>
|
||||
</button>
|
||||
<button name="action_view_fusion_shipping_label" type="object"
|
||||
class="oe_stat_button" icon="fa-print"
|
||||
invisible="not fusion_shipping_label_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Shipping Label</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_fusion_commercial_invoice" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not fusion_commercial_invoice_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Commercial Invoice</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -781,7 +781,7 @@ class FusionTechnicianTask(models.Model):
|
||||
def _inverse_datetime_start(self):
|
||||
"""When datetime_start is changed (e.g. from calendar drag), update date + time."""
|
||||
import pytz
|
||||
user_tz = self._get_local_tz()
|
||||
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
|
||||
for task in self:
|
||||
if task.datetime_start:
|
||||
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
|
||||
@@ -791,7 +791,7 @@ class FusionTechnicianTask(models.Model):
|
||||
def _inverse_datetime_end(self):
|
||||
"""When datetime_end is changed (e.g. from calendar resize), update time_end."""
|
||||
import pytz
|
||||
user_tz = self._get_local_tz()
|
||||
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
|
||||
for task in self:
|
||||
if task.datetime_end:
|
||||
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_task_tz
|
||||
@@ -1,44 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTaskTz(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# _compute_datetimes resolves company resource-calendar tz FIRST, then user tz.
|
||||
# Set BOTH to Toronto so the UTC assertion and the round-trip are deterministic.
|
||||
cls.env.user.tz = 'America/Toronto'
|
||||
cal = cls.env.company.resource_calendar_id
|
||||
if cal:
|
||||
cal.tz = 'America/Toronto'
|
||||
# technician_id is required (domain x_fc_is_field_staff=True) -> make a field tech.
|
||||
cls.tech = cls.env['res.users'].create({
|
||||
'name': 'TZ Test Tech',
|
||||
'login': 'tz_test_tech_svcbook',
|
||||
'x_fc_is_field_staff': True,
|
||||
})
|
||||
# A FUTURE date in July so the task is not "in the past" (the base
|
||||
# _check_no_overlap constraint rejects past dates) and Toronto is firmly
|
||||
# in EDT (-4), keeping the 9:00 -> 13:00 UTC assertion deterministic.
|
||||
cls.task = cls.env['fusion.technician.task'].create({
|
||||
'technician_id': cls.tech.id,
|
||||
'scheduled_date': date(date.today().year + 1, 7, 1),
|
||||
'time_start': 9.0,
|
||||
'time_end': 10.0,
|
||||
'description': 'TZ round-trip test', # description is required (NOT NULL)
|
||||
'is_in_store': True, # avoids the address-required constraint
|
||||
})
|
||||
|
||||
def test_local_to_utc_compute(self):
|
||||
# 9:00 local Toronto (EDT, -4) -> 13:00 UTC stored
|
||||
self.assertEqual(self.task.datetime_start.hour, 13)
|
||||
|
||||
def test_inverse_round_trips_with_same_tz(self):
|
||||
# writing datetime_start back recovers the same local time_start
|
||||
self.task.datetime_start = self.task.datetime_start # force inverse
|
||||
self.task.flush_recordset(['datetime_start'])
|
||||
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
|
||||
53
scripts/verify_service_booking.sh
Executable file → Normal file
53
scripts/verify_service_booking.sh
Executable file → Normal file
@@ -37,11 +37,7 @@ PGPW="${PGPW:-DevSecure2025!}"
|
||||
PGUSER="${PGUSER:-odoo}"
|
||||
|
||||
MODULES="${MODULES:-fusion_tasks,fusion_claims}" # comma list for -u
|
||||
# Scope to THIS feature's test classes — the broad /fusion_claims tag also runs
|
||||
# pre-existing dashboard/wizard tests that fail in this prod-config runner
|
||||
# (CLAUDE.md fusion_repairs note: post_install trips on a pre-existing module),
|
||||
# which is unrelated to this feature. Override TEST_TAGS to widen if desired.
|
||||
TEST_TAGS="${TEST_TAGS:-/fusion_tasks:TestTaskTz,/fusion_claims:TestServiceRate,/fusion_claims:TestServiceBooking}"
|
||||
TEST_TAGS="${TEST_TAGS:-/fusion_tasks,/fusion_claims}"
|
||||
MOD_DIRS=(fusion_tasks fusion_claims) # dirs to stage/deploy
|
||||
|
||||
BRANCH="${BRANCH:-claude/technician-service-booking}"
|
||||
@@ -95,21 +91,17 @@ dexec -e PGPASSWORD="$PGPW" "$DBC" sh -c \
|
||||
>>"$LOG" 2>&1
|
||||
ok "Cloned."
|
||||
|
||||
# ----------------------------- 2. ORPHANED-FK CLEANUP (clone only) -----------
|
||||
# westin-v19 has orphaned rows under VALIDATED FKs (deleted taxes, companies,
|
||||
# journals, ...). A plain pg_dump|psql clone cannot rebuild a validating FK over
|
||||
# orphans, so the clone is MISSING those FKs; Odoo's check_foreign_keys then
|
||||
# re-adds them and fails (e.g. payslip_tags_table.res_company_id=3,
|
||||
# account_payment_method_line.journal_id=35). Generate an orphan-delete for EVERY
|
||||
# single-column FK that exists on PROD (read-only SELECT on prod) and apply it to
|
||||
# the clone. The clone is a throwaway; prod is never modified.
|
||||
# (CLAUDE.md orphan-FK gotcha, generalised beyond the tax tables.)
|
||||
c "Orphaned-FK cleanup (clone only) — general sweep from prod's FK definitions"
|
||||
FKSQL="/tmp/svcbook_fkclean_${STAMP}.sql"
|
||||
printf '%s\n' '\set ON_ERROR_STOP off' > "$FKSQL"
|
||||
dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$PROD_DB" -t -A -c "SELECT format('DELETE FROM %I a WHERE a.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I b WHERE b.%I = a.%I);', src.relname, srcatt.attname, tgt.relname, tgtatt.attname, srcatt.attname) FROM pg_constraint con JOIN pg_class src ON src.oid=con.conrelid JOIN pg_namespace ns ON ns.oid=src.relnamespace AND ns.nspname='public' JOIN pg_class tgt ON tgt.oid=con.confrelid JOIN pg_attribute srcatt ON srcatt.attrelid=con.conrelid AND srcatt.attnum=con.conkey[1] JOIN pg_attribute tgtatt ON tgtatt.attrelid=con.confrelid AND tgtatt.attnum=con.confkey[1] WHERE con.contype='f' AND array_length(con.conkey,1)=1;" >> "$FKSQL" 2>>"$LOG" || true
|
||||
dexec -i -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$CLONE_DB" < "$FKSQL" >>"$LOG" 2>&1 || true
|
||||
ok "Orphan FKs cleared on clone (general sweep, $(grep -c '^DELETE' "$FKSQL" 2>/dev/null || echo 0) FK relations)."
|
||||
# ----------------------------- 2. ORPHAN-TAX-FK CLEANUP (clone only) ---------
|
||||
# westin-v19 has ~3300 orphaned tax m2m rows under validated FKs; a plain
|
||||
# pg_dump|psql clone can't rebuild the validating FK over them -> Odoo fails to
|
||||
# load the registry. Safe to delete ON THE CLONE only. (CLAUDE.md gotcha.)
|
||||
c "Orphaned-tax-FK cleanup (clone only)"
|
||||
psql_clone -c "DELETE FROM product_taxes_rel WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true
|
||||
psql_clone -c "DELETE FROM product_supplier_taxes_rel WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true
|
||||
# sweep any other %_rel table carrying a tax_id column
|
||||
psql_clone -t -A -c "SELECT table_name FROM information_schema.columns WHERE column_name='tax_id' AND table_name LIKE '%\\_rel';" 2>/dev/null \
|
||||
| while read -r t; do [[ -n "$t" ]] && psql_clone -c "DELETE FROM ${t} WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true; done
|
||||
ok "Orphan FKs cleared on clone."
|
||||
|
||||
# ----------------------------- 3. STAGE MODULES (shadow) ---------------------
|
||||
c "Stage modules into $STAGE (shadows prod, prod files untouched)"
|
||||
@@ -122,12 +114,9 @@ ok "Staged: ${MOD_DIRS[*]}"
|
||||
# --test-enable SILENTLY SKIPS without --workers 0; log_level=warn hides test
|
||||
# output -> add --log-level=test. The EXIT CODE is authoritative.
|
||||
run_odoo() { # $1 = extra args
|
||||
# --test-enable forces http_spawn() even with --no-http (Odoo 19), so the test
|
||||
# run binds 8069 (held by the live app) and dies with "Address already in use".
|
||||
# --http-port=0 --gevent-port=0 makes it pick ephemeral ports. (CLAUDE.md gotcha.)
|
||||
dexec "$APP" odoo -d "$CLONE_DB" \
|
||||
--db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \
|
||||
--addons-path="$ADDONS_PATH" --stop-after-init --no-http --http-port=0 --gevent-port=0 $1
|
||||
--addons-path="$ADDONS_PATH" --stop-after-init --no-http $1
|
||||
}
|
||||
|
||||
c "Install/upgrade on clone (catches install/render errors)"
|
||||
@@ -140,22 +129,6 @@ else
|
||||
TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true
|
||||
fi
|
||||
|
||||
# Asset-bundle compile check: a broken SCSS/SASS breaks the ENTIRE
|
||||
# web.assets_backend bundle (the whole backend UI for every user), and `-u` does
|
||||
# NOT compile it — Odoo compiles assets lazily at request time. Force-compile
|
||||
# both bundles here so a stylesheet error fails the gate BEFORE prod, not after.
|
||||
# (CLAUDE.md asset cache-busting #3.)
|
||||
if [[ "${TESTS_OK:-0}" == "1" ]]; then
|
||||
c "Compile asset bundles on clone (catches SCSS errors)"
|
||||
echo "env['ir.qweb']._get_asset_bundle('web.assets_backend').css(); env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css(); print('ASSETS_COMPILED_OK')" \
|
||||
| dexec -i "$APP" odoo shell -d "$CLONE_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" --addons-path="$ADDONS_PATH" --no-http --http-port=0 --gevent-port=0 >>"$LOG" 2>&1 || true
|
||||
if grep -q ASSETS_COMPILED_OK "$LOG"; then
|
||||
ok "Asset bundles compiled OK"
|
||||
else
|
||||
TESTS_OK=0; err "ASSET COMPILE FAILED — see $LOG"; grep -iE 'error|scss|sass|Traceback' "$LOG" | tail -25 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
c "VERIFY RESULT"
|
||||
if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi
|
||||
|
||||
Reference in New Issue
Block a user