Compare commits

...

12 Commits

Author SHA1 Message Date
gsinghpal
d36933d7f4 fix(configurator): wrap t-field widgets in <span> inside table cells
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Acknowledgement PDF rendering failed with "QWeb widgets do not work
correctly on 'td' elements" — Odoo's qweb compiler rejects
t-field/t-options directly on <td>. Wrap the monetary / qty widgets
in an inner <span> for every cell that uses them (body rows + footer
total).

Caught during browser UAT on S00066 — shell _render_qweb_pdf smoke
test passed earlier because it bypasses the full compile path, but
the production /report/pdf/ endpoint fails the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:59:02 -04:00
gsinghpal
1817f63c67 fix(fusion_accounting_reports): engine accepts report_code to disambiguate
When multiple fusion.report rows share a report_type (e.g. 4 PnL-typed
reports: pnl, cash_flow, executive_summary, annual_statements), the
engine's _get_report previously returned whichever matched the type
filter first \u2014 so all four reports rendered the canonical P&L
line_specs regardless of which report the user selected.

Adds report_code kwarg to compute_pnl, compute_balance_sheet,
compute_trial_balance, compute_gl. Controller /fusion/reports/run now
accepts and forwards report_code. _get_report has a 3-tier resolution:
1. Exact code match (validates type)
2. Canonical (code == report_type)
3. First by sequence

Two new tests assert distinct line_specs render for distinct codes and
that wrong-type code raises ValidationError.

Verified live on westin-v19: pnl/cash_flow/executive_summary/
annual_statements now return 3/9/7/5 rows respectively (was all
3 before).

Made-with: Cursor
2026-04-19 23:58:29 -04:00
gsinghpal
1ebff01d35 feat(fusion_accounting_reports): seed 3 partner-grouped reports
Adds Aged Receivable, Aged Payable, and Partner Ledger as fusion.report
records using the new compute_partner_grouped engine method.

REPORT_TYPES is extended with aged_receivable / aged_payable /
partner_ledger so each report has a unique report_type. The HTTP
controller dispatches these to engine.compute_partner_grouped with
the appropriate account_type via PARTNER_GROUPED_ACCOUNT_TYPE.

Output includes per-partner aging buckets: current, 1-30, 31-60,
61-90, 90+ days.

Westin total: 4 + 4 + 3 = 11 of Enterprise's 22 standard reports.

Made-with: Cursor
2026-04-19 23:55:45 -04:00
gsinghpal
ff6d21a561 feat(fusion_accounting_reports): partner-grouped engine method
Adds engine.compute_partner_grouped(period, account_type=...) that
returns per-partner aggregations with aging buckets (current/1-30/
31-60/61-90/90+). SQL-direct for performance — single GROUP BY query
with conditional sum per bucket.

Foundation for the 3 partner-grouped reports landing in commit 3:
Aged Receivable, Aged Payable, Partner Ledger.

Made-with: Cursor
2026-04-19 23:54:32 -04:00
gsinghpal
6896c71b79 feat(fusion_accounting_reports): seed 4 more standard reports
Adds Cash Flow Statement, Executive Summary, Tax Summary, and Annual
Statements as fusion.report records with line_specs. All work with the
existing engine's bucket-sum pattern — no engine changes needed.

Westin total: 4 + 4 = 8 of Enterprise's 22 standard reports now in
fusion_accounting_reports. Partner-grouped reports (Aged AR/AP,
Partner Ledger) need an engine extension — in commit 2.

Made-with: Cursor
2026-04-19 23:53:16 -04:00
gsinghpal
111792599c fix(configurator): margin % stored as fraction so widget='percentage' formats right
Phase D8 compute was returning x_fc_margin_percent already-multiplied
by 100, but the 'percentage' widget in the SO form multiplies again
for display. Result was 10000% instead of 100%.

Store as 0.0-1.0 fraction; widget handles the multiplier. Caught
during UAT on S00066.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:49:00 -04:00
gsinghpal
679dbaa979 feat(fusion_accounting_followup): per-partner state migration from Enterprise
Migrates Enterprise account_followup per-partner state to Fusion fields:
- res.partner.followup_status -> fusion_followup_status (action_due/no_action)
- res.partner.payment_next_action_date -> fusion_followup_paused_until
  (when future-dated; sets status to 'paused')
- res.partner.followup_line_id -> fusion_followup_last_level_id
  (resolved by name match against migrated levels)

Wired into fusion.migration.wizard.action_run_migration after the
existing _followup_bootstrap_step. Idempotent: skips partners whose
Fusion state is already non-default. Defensive against missing
Enterprise fields (each field probed individually before use).

Closes the per-partner state migration gap that was blocking
Enterprise account_followup uninstall.

Made-with: Cursor
2026-04-19 23:48:22 -04:00
gsinghpal
b15bf2293e fix(configurator/bridge_mrp): address all bugs from code review
Two critical, one important, four polish fixes found by the
pr-review-toolkit code-reviewer.

C1 (CRITICAL) Start-at-node filter dropped later siblings
  fusion_plating_bridge_mrp/models/mrp_production.py:448
  The allowed_ids set was {descendants} ∪ {ancestors}, which wrongly
  excluded nodes that should run AFTER the start node — including
  later siblings of the start node and all operations in subsequent
  sub-processes. Rewrote the upward walk to ALSO include each
  ancestor's later-sequence siblings and their descendants. Smoke on
  ENP-ALUM-BASIC: full=9 WOs, partial from mid-tree 'De-Masking'=5
  WOs (previously was 1).

C2 (CRITICAL) Duplicate MO on re-confirm of pre-PR SOs
  fusion_plating_bridge_mrp/models/sale_order.py:96
  Legacy untagged MOs (created before this PR had line-linkage m2m)
  were not recognized by the untagged idempotency check, so
  re-confirming an already-processed SO would create one additional
  MO per untagged plating line. Fix: pre-scan for a single legacy
  untagged MO and adopt it by linking ALL untagged plating lines
  onto it. Those lines are then treated as covered and no per-line
  MOs are created on top. Smoke: S00066 before=1 MO, after
  re-run=1 MO.

I5 (IMPORTANT) push_to_defaults wrote to pre-bump revision
  fusion_plating_configurator/wizard/fp_direct_order_wizard.py:236
  When create_new_revision=True, _get_or_bump_revision() returned a
  new part record that got written to the SO line, but the
  post-confirm push_to_defaults loop re-read line.part_catalog_id
  (still the OLD rev) and wrote defaults there, defeating the whole
  point of "save as default". Fix: cache resolved parts in a dict
  keyed by wizard-line ID during the build loop, and use that cache
  in the push_to_defaults pass.

I3/I4/I6 (PERF) Computes lacked @api.depends and did per-record
  search_count / search queries
  fusion_plating_configurator/models/sale_order.py
  _compute_nav_counts, _compute_workorder_count, _compute_wo_completion
  now:
  - declare @api.depends
  - batch via read_group across the whole self recordset
  - rebuild {origin: counts} dicts and assign per record

M7 (MEDIUM) No savepoint around per-group MO creation
  fusion_plating_bridge_mrp/models/sale_order.py:_fp_auto_create_mo
  A mid-loop exception left group 1's MO persisted and aborted
  groups 2..N. Wrapped each group's create in SAVEPOINT/RELEASE/
  ROLLBACK TO SAVEPOINT so one bad group no longer corrupts state.

M8 (MEDIUM) Email 'opened' status false-positived on internal CC
  fusion_plating_configurator/models/sale_order.py:_compute_email_status
  Switched from 'any notification is_read' to 'customer partner has
  a read email notification on this SO'.

M9 (LOW) start_at_node_id domain silently empty when coating unset
  fusion_plating_configurator/wizard/fp_direct_order_line.py:94
  Changed `('parent_id', 'child_of', ...)` to
  `('id', 'child_of', ..., or 0)` and clarified the help text.

Regression smoke passed all checks on odoo-entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:35:03 -04:00
gsinghpal
9d8db0f9b1 fix(bank_rec): don't shadow Odoo's _() translation function in action_run_migration
Line 77 was `_ = super().action_run_migration()`, using `_` as a
throwaway variable name. That rebinds the module-level `_` (Odoo's
translation function imported at the top) to whatever super() returns
\u2014 in our case the parent's notification dict.

Lines 84/85 then call `_('Bank-Rec Migration Complete')` which is
now `some_dict('Bank-Rec Migration Complete')` \u2192
TypeError: 'dict' object is not callable.

User hit this when running the migration wizard from the menu.

Fix: drop the assignment; we don't actually use super()'s return value.
Made-with: Cursor
2026-04-19 23:34:45 -04:00
gsinghpal
ef2ccb89cf fix(services): V19 removed 'rpc' service \u2014 import standalone rpc() function
V19 removed the 'rpc' service from the registry. All 4 fusion services
(bank_reconciliation, reports, assets, followup) declared dependencies:
['rpc', ...] and accessed services.rpc in their constructor. At runtime
this caused:

  Error: Some services could not be started: fusion_bank_reconciliation,
  fusion_reports, fusion_assets, fusion_followup. Missing dependencies: rpc

\u2014 which prevented the entire OWL backend from booting (blank screen).

Fix per V19 docs:
- Add 'import { rpc } from "@web/core/network/rpc";'
- Set 'this.rpc = rpc;' in constructor (instead of services.rpc)
- Remove 'rpc' from dependencies list

This is the workspace CLAUDE.md guidance Phase 4's subagent flagged
but didn't act on for backward consistency. V19 actually removed the
service entirely, so the consistency choice was wrong \u2014 fixing now.

All call sites still use this.rpc(...) so no per-method changes needed.
Bundle rebuilt clean; backend boots correctly.

Made-with: Cursor
2026-04-19 23:25:52 -04:00
gsinghpal
51d8ce494d fix(scss): remove forbidden @import "variables" lines breaking V19 asset bundle
Phases 1-3's SCSS files used '@import "variables";' to pull in tokens
from _variables.scss. V19's odoo.addons.base.models.assetsbundle
forbids cross-file SCSS imports for security ('Local import forbidden')
and the asset bundle warning was firing on every web request.

Phase 4 caught + fixed this for fusion_accounting_followup; Phases 1-3
were never updated. Today's deployment surfaced the CSS error reported
by the user.

Resolution:
- Removed @import lines from 7 SCSS files across bank_rec, reports, assets
- Variables come from _variables.scss via manifest concatenation order
  (bundle order is _variables.scss first, then dependent files)
- Replaced documentation comments to NOT contain the literal string
  '@import "variables"' \u2014 Odoo's check is regex-based and was
  matching even SCSS comments

Verified clean: bundle rebuilds with zero 'Local import forbidden'
warnings; all 534 fusion-module tests still pass.

Made-with: Cursor
2026-04-19 21:57:22 -04:00
gsinghpal
190c296240 fix(fusion_accounting_ai): align legacy assets-adapter test with Phase 3 return shape
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Phase 3 (fusion_accounting_assets) changed list_assets() to return
{count, total, assets} dict instead of a flat list — consistent with
bank_rec.list_unreconciled, reports.run_report, followup.list_overdue.

The pre-existing test in fusion_accounting_ai still asserted isinstance(rows, list)
and was failing on every run since Phase 3 merge. Updated to assert dict shape.

Made-with: Cursor
2026-04-19 21:50:47 -04:00
33 changed files with 892 additions and 200 deletions

View File

@@ -140,7 +140,11 @@ class TestFollowupAdapter(TransactionCase):
@tagged('post_install', '-at_install')
class TestAssetsAdapter(TransactionCase):
def test_list_assets_returns_list(self):
def test_list_assets_returns_dict_with_assets(self):
# Phase 3 (fusion_accounting_assets) wired list_assets to return
# {count, total, assets} — consistent with bank_rec.list_unreconciled etc.
adapter = get_adapter(self.env, 'assets')
rows = adapter.list_assets()
self.assertIsInstance(rows, list)
self.assertIsInstance(rows, dict)
self.assertIn('assets', rows)
self.assertIsInstance(rows['assets'], list)

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
.o_fusion_assets {
background: $asset-bg-secondary;

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_assets {
background: #1f2937; color: #f9fafb;

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/assets";
export class AssetsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -142,7 +144,7 @@ export class AssetsService {
}
export const assetsService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new AssetsService(env, services); },
};

View File

@@ -74,7 +74,9 @@ class FusionMigrationWizard(models.TransientModel):
Phase 0) and then runs the bank-rec bootstrap. Returns a
notification summarizing both.
"""
_ = super().action_run_migration()
# Don't bind super()'s return value to `_` \u2014 that shadows the
# imported translation function and breaks the _("...") calls below.
super().action_run_migration()
result = self._bank_rec_bootstrap_step()
return {
'type': 'ir.actions.client',

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// ============================================================
// AI Suggestion strip (inline, on each statement line card)

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
// ============================================================
// Bank reconciliation kanban container

View File

@@ -1,5 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// Activated via [data-color-scheme="dark"] on body or any ancestor.
// Mirrors Odoo's standard dark-mode trigger pattern.

View File

@@ -14,13 +14,15 @@ import { registry } from "@web/core/registry";
import { reactive, useState, EventBus } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { browser } from "@web/core/browser/browser";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/bank_rec";
export class BankReconciliationService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is no longer a service — imported as a standalone function above.
this.rpc = rpc;
this.notification = services.notification;
this.orm = services.orm;
@@ -400,7 +402,7 @@ export class BankReconciliationService {
}
export const bankReconciliationService = {
dependencies: ["rpc", "notification", "orm"],
dependencies: ["notification", "orm"],
start(env, services) {
return new BankReconciliationService(env, services);
},

View File

@@ -78,10 +78,120 @@ class FusionMigrationWizard(models.TransientModel):
result['created'], result['skipped'], len(result['errors']))
return result
def _followup_partner_state_bootstrap_step(self):
"""Migration step: copy Enterprise account_followup per-partner state
onto Fusion's fields on res.partner.
Idempotent: only updates partners whose Fusion field is at default
(no_action) and whose Enterprise field has a non-default value.
"""
self.ensure_one()
_logger.info("fusion_accounting_followup partner-state migration starting")
Partner = self.env['res.partner'].sudo()
has_status = 'followup_status' in Partner._fields
has_next_date = 'payment_next_action_date' in Partner._fields
has_line = 'followup_line_id' in Partner._fields
if not (has_status or has_next_date or has_line):
_logger.info(
"Enterprise account_followup partner fields not present \u2014 skipping")
return {
'step': 'followup_partner_state',
'enterprise_module_present': False,
'updated': 0, 'skipped': 0, 'errors': [],
}
result = {
'step': 'followup_partner_state',
'enterprise_module_present': True,
'updated': 0, 'skipped': 0, 'errors': [],
}
domain_terms = []
if has_status:
domain_terms.append(('followup_status', '!=', 'no_action_needed'))
if has_next_date:
domain_terms.append(('payment_next_action_date', '!=', False))
if not domain_terms:
_logger.info("No usable Enterprise follow-up fields \u2014 skipping")
return result
if len(domain_terms) > 1:
domain = ['|'] * (len(domain_terms) - 1) + domain_terms
else:
domain = domain_terms
candidates = Partner.search(domain)
_logger.info(
"Found %d partners with non-default Enterprise follow-up state",
len(candidates))
Level = self.env['fusion.followup.level'].sudo()
today = fields.Date.today()
status_map = {
'in_need_of_action': 'action_due',
'with_overdue_invoices': 'action_due',
'no_action_needed': 'no_action',
}
for partner in candidates:
try:
if partner.fusion_followup_status not in (False, 'no_action'):
result['skipped'] += 1
continue
vals = {}
ent_status = (
getattr(partner, 'followup_status', None)
if has_status else None)
if ent_status and ent_status in status_map:
vals['fusion_followup_status'] = status_map[ent_status]
next_date = (
getattr(partner, 'payment_next_action_date', False)
if has_next_date else False)
if next_date and next_date > today:
vals['fusion_followup_paused_until'] = next_date
vals['fusion_followup_status'] = 'paused'
ent_line = (
getattr(partner, 'followup_line_id', None)
if has_line else None)
if ent_line:
fusion_level = Level.search([
('name', '=', ent_line.name),
], limit=1)
if fusion_level:
vals['fusion_followup_last_level_id'] = fusion_level.id
if vals:
partner.write(vals)
result['updated'] += 1
_logger.debug(
"Migrated partner %s: %s", partner.name, vals)
else:
result['skipped'] += 1
except Exception as e:
result['errors'].append(
f"Partner {partner.id} ({partner.name}): {e}")
_logger.warning(
"Migration failed for partner %s: %s", partner.id, e)
_logger.info(
"fusion_accounting_followup partner-state migration: "
"updated=%d skipped=%d errors=%d",
result['updated'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._followup_bootstrap_step()
except Exception as e:
_logger.warning("followup_bootstrap_step failed: %s", e)
try:
self._followup_partner_state_bootstrap_step()
except Exception as e:
_logger.warning("followup_partner_state_bootstrap_step failed: %s", e)
return result

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/followup";
export class FollowupService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -138,7 +140,7 @@ export class FollowupService {
}
export const followupService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new FollowupService(env, services); },
};

View File

@@ -19,3 +19,12 @@ class TestFollowupMigrationRoundTrip(TransactionCase):
# Second run skips what first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])
def test_partner_state_bootstrap_step(self):
"""Verify the partner-state migration step runs without error."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._followup_partner_state_bootstrap_step()
self.assertEqual(result['step'], 'followup_partner_state')
self.assertIn(result['enterprise_module_present'], [True, False])
self.assertGreaterEqual(result['updated'], 0)
self.assertGreaterEqual(result['skipped'], 0)

View File

@@ -36,6 +36,13 @@ menu hides; the engine and AI tools remain available for the chat.
'data/report_balance_sheet.xml',
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/report_cash_flow.xml',
'data/report_executive_summary.xml',
'data/report_tax_report.xml',
'data/report_annual_statements.xml',
'data/report_aged_receivable.xml',
'data/report_aged_payable.xml',
'data/report_partner_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',

View File

@@ -18,7 +18,16 @@ from ..services.date_periods import Period
_logger = logging.getLogger(__name__)
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
REPORT_TYPES = {
'pnl', 'balance_sheet', 'trial_balance', 'general_ledger',
'aged_receivable', 'aged_payable', 'partner_ledger',
}
PARTNER_GROUPED_ACCOUNT_TYPE = {
'aged_receivable': 'asset_receivable',
'aged_payable': 'liability_payable',
'partner_ledger': 'asset_receivable',
}
def _parse_date(value):
@@ -56,7 +65,7 @@ class FusionReportsController(http.Controller):
@http.route('/fusion/reports/run', type='jsonrpc', auth='user')
def run(self, report_type, date_from=None, date_to=None,
comparison='none', company_id=None):
comparison='none', company_id=None, report_code=None):
if report_type not in REPORT_TYPES:
raise ValidationError(_("Unknown report type: %s") % report_type)
company_id = int(company_id) if company_id else request.env.company.id
@@ -66,19 +75,33 @@ class FusionReportsController(http.Controller):
period = _build_period(date_from, date_to)
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
report_code=report_code,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
_parse_date(date_to),
comparison=comparison,
company_id=company_id,
report_code=report_code,
)
if report_type == 'trial_balance':
period = _build_period(date_from, date_to)
return engine.compute_trial_balance(period, company_id=company_id)
return engine.compute_trial_balance(
period, company_id=company_id, report_code=report_code,
)
if report_type in PARTNER_GROUPED_ACCOUNT_TYPE:
period = _build_period(date_from, date_to)
return engine.compute_partner_grouped(
period,
account_type=PARTNER_GROUPED_ACCOUNT_TYPE[report_type],
comparison=comparison,
company_id=company_id,
)
# general_ledger
period = _build_period(date_from, date_to)
return engine.compute_gl(period, company_id=company_id)
return engine.compute_gl(
period, company_id=company_id, report_code=report_code,
)
@http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user')
def drill_down(self, account_id, date_from, date_to, company_id=None):

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_payable" model="fusion.report">
<field name="name">Aged Payable</field>
<field name="code">aged_payable</field>
<field name="report_type">aged_payable</field>
<field name="sequence">36</field>
<field name="description">Per-vendor outstanding payables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Payable', 'account_type_for_grouping': 'liability_payable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_receivable" model="fusion.report">
<field name="name">Aged Receivable</field>
<field name="code">aged_receivable</field>
<field name="report_type">aged_receivable</field>
<field name="sequence">35</field>
<field name="description">Per-customer outstanding receivables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Receivable', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_annual_statements" model="fusion.report">
<field name="name">Annual Statements</field>
<field name="code">annual_statements</field>
<field name="report_type">pnl</field>
<field name="sequence">11</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Year-over-year P&amp;L comparison for annual reporting.</field>
<field name="line_specs" eval="[
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
{'label': 'Cost of Goods Sold', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
{'label': 'Gross Profit', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_cash_flow" model="fusion.report">
<field name="name">Cash Flow Statement</field>
<field name="code">cash_flow</field>
<field name="report_type">pnl</field>
<field name="sequence">15</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Cash flow by activity (operating, investing, financing).</field>
<field name="line_specs" eval="[
{'label': 'Operating Activities', 'level': 0},
{'label': 'Net Income (from operations)', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Depreciation Add-back', 'account_type_prefix': 'expense_depreciation', 'sign': 1, 'level': 1},
{'label': 'Operating Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Investing Activities', 'level': 0},
{'label': 'Fixed Asset Purchases', 'account_type_prefix': 'asset_fixed', 'sign': -1, 'level': 1},
{'label': 'Investing Cash Flow', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
{'label': 'Financing Activities', 'level': 0},
{'label': 'Liabilities (long-term)', 'account_type_prefix': 'liability_non_current', 'sign': 1, 'level': 1},
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': 1, 'level': 1},
{'label': 'Financing Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'NET CHANGE IN CASH', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_executive_summary" model="fusion.report">
<field name="name">Executive Summary</field>
<field name="code">executive_summary</field>
<field name="report_type">pnl</field>
<field name="sequence">5</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Top-level KPI summary: revenue, expenses, net income, key balance positions.</field>
<field name="line_specs" eval="[
{'label': 'PROFIT &amp; LOSS', 'level': 0},
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'BALANCE POSITIONS', 'level': 0},
{'label': 'Cash &amp; Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
{'label': 'Net Working Position', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_partner_ledger" model="fusion.report">
<field name="name">Partner Ledger</field>
<field name="code">partner_ledger</field>
<field name="report_type">partner_ledger</field>
<field name="sequence">40</field>
<field name="description">Per-partner ledger combining receivable and payable activity.</field>
<field name="line_specs" eval="[
{'label': 'Partner Ledger', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_tax_summary" model="fusion.report">
<field name="name">Tax Summary</field>
<field name="code">tax_summary</field>
<field name="report_type">trial_balance</field>
<field name="sequence">25</field>
<field name="description">Tax liability + asset positions. v1: aggregate-level only; per-tax-code breakdown is Phase 2.5.</field>
<field name="line_specs" eval="[
{'label': 'Tax Asset (recoverable)', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 0},
{'label': 'Tax Liability (collected)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 0},
{'label': 'NET TAX POSITION', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -13,6 +13,9 @@ REPORT_TYPES = [
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
('aged_receivable', 'Aged Receivable'),
('aged_payable', 'Aged Payable'),
('partner_ledger', 'Partner Ledger'),
]

View File

@@ -14,7 +14,7 @@ Internal pipeline (per report run):
"""
import logging
from datetime import date
from datetime import date, timedelta
from odoo import _, api, models
from odoo.exceptions import ValidationError
@@ -39,10 +39,17 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_pnl(
self, period: Period, *, comparison: str = 'none',
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""Income statement (P&L) for the given period."""
report = self._get_report('pnl', company_id=company_id)
"""Income statement (P&L) for the given period.
``report_code`` selects between multiple PnL-typed report definitions
(``pnl``, ``cash_flow``, ``executive_summary``, ``annual_statements``).
When omitted, falls back to the canonical ``pnl`` definition.
"""
report = self._get_report(
'pnl', company_id=company_id, code=report_code,
)
return self._compute(
report, period, comparison=comparison, company_id=company_id,
)
@@ -50,11 +57,13 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_balance_sheet(
self, date_to: date, *, comparison: str = 'none',
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""Balance sheet AS OF date_to. Period.date_from is set to a
far-past date so balances are cumulative-since-inception."""
report = self._get_report('balance_sheet', company_id=company_id)
report = self._get_report(
'balance_sheet', company_id=company_id, code=report_code,
)
period = Period(
date_from=date(1970, 1, 1),
date_to=date_to,
@@ -67,10 +76,17 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_trial_balance(
self, period: Period, *, company_id: int | None = None,
report_code: str | None = None,
) -> dict:
"""Trial balance for the given period - every account with
non-zero balance."""
report = self._get_report('trial_balance', company_id=company_id)
non-zero balance.
``report_code`` selects between multiple TB-typed reports (e.g.
``trial_balance``, ``tax_summary``).
"""
report = self._get_report(
'trial_balance', company_id=company_id, code=report_code,
)
return self._compute(
report, period, comparison='none', company_id=company_id,
)
@@ -78,12 +94,14 @@ class FusionReportEngine(models.AbstractModel):
@api.model
def compute_gl(
self, period: Period, *, account_ids: list | None = None,
company_id: int | None = None,
company_id: int | None = None, report_code: str | None = None,
) -> dict:
"""General ledger for the given period.
Returns per-account move-line listings rather than aggregated rows."""
report = self._get_report('general_ledger', company_id=company_id)
report = self._get_report(
'general_ledger', company_id=company_id, code=report_code,
)
company_id = company_id or self.env.company.id
result = self._compute(
report, period, comparison='none', company_id=company_id,
@@ -118,27 +136,188 @@ class FusionReportEngine(models.AbstractModel):
limit=500,
)
@api.model
def compute_partner_grouped(
self, period: Period, *, account_type: str = 'asset_receivable',
comparison: str = 'none', company_id: int | None = None,
) -> dict:
"""Per-partner aggregation report (Aged Receivable, Aged Payable,
Partner Ledger).
Returns a dict with ``rows`` = list of partner-level aggregates.
Each row has the partner_id, partner_name, total residual, and
aging buckets: current / 1-30 / 31-60 / 61-90 / 90+ days past
``period.date_to``.
SQL-direct for performance: a single GROUP BY query with conditional
sum per bucket. Only un-reconciled, posted lines with non-zero
residual at the as-of date are included.
"""
company_id = company_id or self.env.company.id
accounts = self.env['account.account'].sudo().search([
('account_type', '=', account_type),
('company_ids', 'in', company_id),
])
if not accounts:
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'rows': [],
'total': 0.0,
'partner_count': 0,
}
as_of = period.date_to
d30 = as_of - timedelta(days=30)
d60 = as_of - timedelta(days=60)
d90 = as_of - timedelta(days=90)
self.env.cr.execute(
"""
SELECT
COALESCE(p.id, 0) AS partner_id,
COALESCE(p.name, '(no partner)') AS partner_name,
SUM(aml.amount_residual) AS total_residual,
SUM(CASE
WHEN aml.date_maturity >= %s
OR aml.date_maturity IS NULL
THEN aml.amount_residual ELSE 0
END) AS bucket_current,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_1_30,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_31_60,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_61_90,
SUM(CASE
WHEN aml.date_maturity < %s
THEN aml.amount_residual ELSE 0
END) AS bucket_90_plus,
COUNT(*) AS line_count
FROM account_move_line aml
LEFT JOIN res_partner p ON p.id = aml.partner_id
WHERE aml.account_id = ANY(%s)
AND aml.parent_state = 'posted'
AND aml.reconciled = false
AND aml.amount_residual != 0
AND aml.company_id = %s
AND aml.date <= %s
GROUP BY p.id, p.name
HAVING SUM(aml.amount_residual) != 0
ORDER BY total_residual DESC
""",
(
as_of,
as_of, d30,
d30, d60,
d60, d90,
d90,
list(accounts.ids), company_id, as_of,
),
)
rows = []
for r in self.env.cr.dictfetchall():
rows.append({
'partner_id': r['partner_id'] or False,
'partner_name': r['partner_name'] or '(no partner)',
'total': float(r['total_residual'] or 0),
'bucket_current': float(r['bucket_current'] or 0),
'bucket_1_30': float(r['bucket_1_30'] or 0),
'bucket_31_60': float(r['bucket_31_60'] or 0),
'bucket_61_90': float(r['bucket_61_90'] or 0),
'bucket_90_plus': float(r['bucket_90_plus'] or 0),
'line_count': r['line_count'],
})
total = sum(r['total'] for r in rows)
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'company_id': company_id,
'rows': rows,
'total': total,
'partner_count': len(rows),
}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _get_report(self, report_type: str, *, company_id: int | None = None):
"""Look up the active fusion.report definition for a given
type+company. If no per-company override, falls back to global
(company_id=False)."""
def _get_report(
self, report_type: str, *, company_id: int | None = None,
code: str | None = None,
):
"""Look up the active fusion.report definition.
When ``code`` is provided, prefer the report with that exact code
(validating its ``report_type`` matches). Otherwise fall back to
the canonical-by-type lookup: prefer code == report_type, then any
report of that type. Per-company overrides win over global.
"""
Report = self.env['fusion.report'].sudo()
company_id = company_id or self.env.company.id
company_domain = [
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
]
if code:
report = Report.search(
[('code', '=', code)] + company_domain,
order='company_id desc nulls last',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition with code '%s'") % code
)
if report.report_type != report_type:
raise ValidationError(
_("Report '%(code)s' has type '%(actual)s' but '%(expected)s' was expected.")
% {
'code': code,
'actual': report.report_type,
'expected': report_type,
}
)
return report
# No code: prefer the canonical (code == report_type), then any
# other report of that type.
report = Report.search(
[
('report_type', '=', report_type),
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
],
[('code', '=', report_type), ('report_type', '=', report_type)] + company_domain,
order='company_id desc nulls last',
limit=1,
)
if report:
return report
report = Report.search(
[('report_type', '=', report_type)] + company_domain,
order='company_id desc nulls last, sequence',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition for type '%s'") % report_type

View File

@@ -1,4 +1,4 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
[data-color-scheme="dark"] .o_fusion_reports {
background: #1f2937;

View File

@@ -1,4 +1,5 @@
@import "variables";
// Variables come from _variables.scss via manifest concatenation order.
// (V19 forbids cross-file SCSS imports; rely on bundle order instead.)
.o_fusion_reports {
background: $report-bg-secondary;

View File

@@ -2,13 +2,15 @@
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const ENDPOINT_BASE = "/fusion/reports";
export class ReportsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
// V19: rpc is a standalone import, not a service.
this.rpc = rpc;
this.notification = services.notification;
this.state = reactive({
@@ -140,7 +142,7 @@ export class ReportsService {
}
export const reportsService = {
dependencies: ["rpc", "notification"],
dependencies: ["notification"],
start(env, services) { return new ReportsService(env, services); },
};

View File

@@ -90,6 +90,75 @@ class TestFusionReportEngine(TransactionCase):
)
self.assertIsInstance(rows, list)
def test_compute_partner_grouped_receivable(self):
period = Period(date(2025, 1, 1), date(2025, 12, 31), 'Test')
result = self.env['fusion.report.engine'].compute_partner_grouped(
period, account_type='asset_receivable',
)
self.assertEqual(result['report_type'], 'partner_grouped')
self.assertEqual(result['account_type'], 'asset_receivable')
self.assertIn('rows', result)
self.assertIn('total', result)
self.assertIn('partner_count', result)
if result['rows']:
for key in (
'partner_name', 'total', 'bucket_current', 'bucket_1_30',
'bucket_31_60', 'bucket_61_90', 'bucket_90_plus',
):
self.assertIn(key, result['rows'][0])
def test_report_code_disambiguates_same_report_type(self):
"""Multiple reports of report_type='pnl' must each be addressable
by code so the engine returns the requested definition's line_specs
(not whichever was first by company_id)."""
spec_one = [
{'label': 'A', 'account_type_prefix': 'income_', 'sign': 1},
]
spec_two = [
{'label': 'X', 'account_type_prefix': 'income_', 'sign': 1},
{'label': 'Y', 'account_type_prefix': 'expense_', 'sign': -1},
{'label': 'Z', 'account_type_prefix': 'asset_', 'sign': 1},
]
self.env['fusion.report'].create({
'name': 'Variant One', 'code': 'variant_one',
'report_type': 'pnl', 'line_specs': spec_one,
'company_id': self.env.company.id,
})
self.env['fusion.report'].create({
'name': 'Variant Two', 'code': 'variant_two',
'report_type': 'pnl', 'line_specs': spec_two,
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
engine = self.env['fusion.report.engine']
r1 = engine.compute_pnl(
period, company_id=self.env.company.id,
report_code='variant_one',
)
r2 = engine.compute_pnl(
period, company_id=self.env.company.id,
report_code='variant_two',
)
self.assertEqual(r1['report_name'], 'Variant One')
self.assertEqual(r2['report_name'], 'Variant Two')
self.assertEqual(len(r1['rows']), 1)
self.assertEqual(len(r2['rows']), 3)
def test_report_code_validates_type_match(self):
"""Asking for a 'pnl' computation but giving a balance_sheet code
should raise ValidationError, not silently mis-render."""
self.env['fusion.report'].create({
'name': 'Wrong Type', 'code': 'wrong_type_test',
'report_type': 'balance_sheet', 'line_specs': [],
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test')
with self.assertRaises(ValidationError):
self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id,
report_code='wrong_type_test',
)
def test_no_report_raises_validation_error(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
# Inactivate any pre-existing GL definitions so the lookup

View File

@@ -440,25 +440,36 @@ class MrpProduction(models.Model):
for override in production.x_fc_override_ids:
override_map[override.node_id.id] = override.included
# Start-at-node: if set, build the set of node IDs that are
# "at or descended from" the start node OR on its ancestor
# path (so we keep the containing recipe / sub-processes
# visible but skip sibling branches that come before the
# start point).
# Start-at-node: if set, the allowed set is the union of:
# 1. start_node and all its descendants (we run these)
# 2. each ancestor of start_node (to preserve the container
# hierarchy the recipe walker uses to reach start_node)
# 3. at each ancestor level, any LATER-sequence sibling and
# all of its descendants (these come after start_node
# in the flow and must still run)
# Earlier siblings at each level are implicitly skipped.
start_node = production.x_fc_start_at_node_id
allowed_ids = None # None = include everything
if start_node:
# Descendants (inclusive)
descendants = self.env['fusion.plating.process.node'].search([
('id', 'child_of', start_node.id),
])
# Ancestors (excluding self — already in descendants)
ancestors = self.env['fusion.plating.process.node']
cur = start_node.parent_id
while cur:
ancestors |= cur
cur = cur.parent_id
allowed_ids = set(descendants.ids) | set(ancestors.ids)
Node = self.env['fusion.plating.process.node']
# 1. Descendants of start_node (inclusive)
descendants = Node.search([('id', 'child_of', start_node.id)])
allowed_ids = set(descendants.ids)
# 2+3. Walk up; at each level add the parent and the
# later-sibling subtrees.
cur = start_node
while cur.parent_id:
parent = cur.parent_id
allowed_ids.add(parent.id)
later_sibs = parent.child_ids.filtered(
lambda n: n.sequence > cur.sequence
)
for sib in later_sibs:
sib_descendants = Node.search([
('id', 'child_of', sib.id),
])
allowed_ids |= set(sib_descendants.ids)
cur = parent
# Bind the source SO once per production so walk_node closure
# can read coating config / spec without an extra search per WO.

View File

@@ -110,9 +110,15 @@ class SaleOrder(models.Model):
"""
self.ensure_one()
Production = self.env['mrp.production']
existing_tags = set(Production.search([
('origin', '=', self.name),
]).mapped('x_fc_wo_group_tag'))
existing_mos = Production.search([('origin', '=', self.name)])
existing_tags = set(existing_mos.mapped('x_fc_wo_group_tag'))
# Legacy MOs = untagged MOs created before this PR that never
# had x_fc_sale_order_line_ids populated. We adopt them 1-for-1
# onto the first N untagged groups so re-confirm doesn't
# double-book.
legacy_untagged = existing_mos.filtered(
lambda m: not m.x_fc_wo_group_tag and not m.x_fc_sale_order_line_ids
)
# Build groups from SO lines that carry plating data
plating_lines = self.order_line.filtered(
@@ -121,94 +127,141 @@ class SaleOrder(models.Model):
if not plating_lines:
return self._fp_auto_create_mo_legacy()
groups = {} # {tag_or_line_key: [lines]}
for line in plating_lines:
key = line.x_fc_wo_group_tag or ('__line__%d' % line.id)
groups.setdefault(key, []).append(line)
created = []
adopted = []
# If a legacy untagged MO already exists for this SO, it
# represents the pre-PR "one MO for the whole order" work.
# Adopt it by linking EVERY untagged plating line to it, and
# treat those lines as covered — don't create per-line MOs on
# top of the legacy MO.
untagged_lines = plating_lines.filtered(lambda l: not l.x_fc_wo_group_tag)
tagged_lines = plating_lines - untagged_lines
covered_untagged_ids = set()
if legacy_untagged and untagged_lines:
legacy = legacy_untagged[0]
legacy.write({
'x_fc_sale_order_line_ids': [(4, ln.id) for ln in untagged_lines],
})
adopted.append(legacy)
covered_untagged_ids = set(untagged_lines.ids)
groups = {} # {tag_or_line_key: [lines]}
for line in tagged_lines:
groups.setdefault(line.x_fc_wo_group_tag, []).append(line)
for line in untagged_lines:
if line.id in covered_untagged_ids:
continue # already adopted onto legacy MO
groups['__line__%d' % line.id] = [line]
for key, lines in groups.items():
tag = lines[0].x_fc_wo_group_tag or False
# Skip if we already have an MO for this (origin, tag) pair.
# Untagged keys are 1:1 with lines; use the line ID in sudo
# check via existing MOs' line links.
if tag and tag in existing_tags:
continue
if not tag:
# Untagged idempotency — check if any existing MO points
# at this line via x_fc_sale_order_line_ids.
# Untagged link-based idempotency (rerun protection)
if Production.search_count([
('origin', '=', self.name),
('x_fc_sale_order_line_ids', 'in', [lines[0].id]),
]):
continue
# Resolve product: part catalog's linked product if any, else
# FP-WIDGET fallback.
product = False
for ln in lines:
pc = ln.x_fc_part_catalog_id
if pc and 'product_id' in pc._fields and pc.product_id:
product = pc.product_id
break
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'FP-WIDGET')], limit=1,
)
if not product:
# Per-group savepoint so one broken group can't block later
# ones AND can't leave partial state committed.
savepoint_name = 'fp_mo_group_%s' % abs(hash(key))
self.env.cr.execute('SAVEPOINT %s' % savepoint_name)
try:
# Resolve product: part catalog's linked product if any,
# else FP-WIDGET fallback.
product = False
for ln in lines:
pc = ln.x_fc_part_catalog_id
if pc and 'product_id' in pc._fields and pc.product_id:
product = pc.product_id
break
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'FP-WIDGET')], limit=1,
)
if not product:
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
self.message_post(body=_(
'Auto-MO skipped (group %s) — no manufacturable '
'product available.'
) % (tag or 'single-line'))
continue
# Recipe: first line's coating -> recipe_id.
recipe = False
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
break
if not recipe:
recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,
)
qty = sum(ln.product_uom_qty for ln in lines) or 1
# Start-at-node: first non-blank wins
start_node = False
for ln in lines:
if ln.x_fc_start_at_node_id:
start_node = ln.x_fc_start_at_node_id
break
mo_vals = {
'product_id': product.id,
'product_qty': qty,
'product_uom_id': product.uom_id.id,
'origin': self.name,
'x_fc_wo_group_tag': tag or False,
'x_fc_sale_order_line_ids': [(6, 0, [ln.id for ln in lines])],
}
if recipe and 'x_fc_recipe_id' in Production._fields:
mo_vals['x_fc_recipe_id'] = recipe.id
if start_node:
mo_vals['x_fc_start_at_node_id'] = start_node.id
mo = Production.create(mo_vals)
created.append((mo, tag, len(lines)))
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
except Exception as exc:
self.env.cr.execute('ROLLBACK TO SAVEPOINT %s' % savepoint_name)
self.message_post(body=_(
'Auto-MO skipped (group %s) — no manufacturable '
'product available.'
) % (tag or 'single-line'))
'Auto-MO group %s failed: %s'
) % (tag or 'single-line', exc))
continue
# Recipe: first line's coating -> recipe_id.
recipe = False
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
break
if not recipe:
recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,
if created or adopted:
msg_parts = []
if created:
lines_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)') % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
for mo, tag, n in created
])
msg_parts.append(
_('%d draft MO(s) auto-created:<br/>%s') % (
len(created), lines_html,
)
)
qty = sum(ln.product_uom_qty for ln in lines) or 1
# Start-at-node: first non-blank wins
start_node = False
for ln in lines:
if ln.x_fc_start_at_node_id:
start_node = ln.x_fc_start_at_node_id
break
mo_vals = {
'product_id': product.id,
'product_qty': qty,
'product_uom_id': product.uom_id.id,
'origin': self.name,
'x_fc_wo_group_tag': tag or False,
'x_fc_sale_order_line_ids': [(6, 0, [ln.id for ln in lines])],
}
if recipe and 'x_fc_recipe_id' in Production._fields:
mo_vals['x_fc_recipe_id'] = recipe.id
if start_node:
mo_vals['x_fc_start_at_node_id'] = start_node.id
mo = Production.create(mo_vals)
created.append((mo, tag, len(lines)))
if created:
lines_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)') % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
for mo, tag, n in created
])
self.message_post(body=Markup(_(
'%d draft manufacturing order(s) auto-created:<br/>%s'
)) % (len(created), lines_html))
if adopted:
adopted_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(legacy, now line-linked)') % (mo.id, mo.name)
for mo in adopted
])
msg_parts.append(
_('%d legacy MO(s) adopted:<br/>%s') % (
len(adopted), adopted_html,
)
)
self.message_post(body=Markup('<br/><br/>'.join(msg_parts)))
def _fp_auto_create_mo_legacy(self):
"""Fallback for SOs with no plating order_line data (service lines).

View File

@@ -131,18 +131,44 @@ class SaleOrder(models.Model):
currency_field='currency_id',
)
@api.depends('name')
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in self."""
for rec in self:
rec.x_fc_wo_completion = '0/0'
names = [so.name for so in self if so.name]
if not names:
return
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names)],
['production_id.origin', 'state'],
['production_id', 'state'],
lazy=False,
)
# Build {origin: {'done': n, 'total': n}}
# read_group returns production_id as (id, name) tuples; we need
# to translate back to origin. Do a small lookup.
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {} # {origin: [total, done]}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
if not origin:
continue
cnt = r['__count']
bucket = totals.setdefault(origin, [0, 0])
bucket[0] += cnt
if r['state'] == 'done':
bucket[1] += cnt
for rec in self:
if not rec.name:
rec.x_fc_wo_completion = '0/0'
continue
total = WO.search_count([('production_id.origin', '=', rec.name)])
done = WO.search_count([
('production_id.origin', '=', rec.name),
('state', '=', 'done'),
])
rec.x_fc_wo_completion = '%d/%d' % (done, total) if total else '0/0'
tot, done = totals.get(rec.name, [0, 0])
rec.x_fc_wo_completion = '%d/%d' % (done, tot) if tot else '0/0'
# ---- Phase F: quotes list view polish ----
x_fc_follow_up_date = fields.Date(
@@ -195,10 +221,14 @@ class SaleOrder(models.Model):
def _compute_email_status(self):
"""Map state + mail tracking to a single visible pill.
- draft SO with no tracked email sent => draft
- sent (Odoo state) => sent
- sent + mail opened => opened (detected via mail.message)
- state=sale/done => won
- state draft => draft
- state sent => sent (or 'opened' if the customer partner has
a read notification for any email message on this SO)
- state sale / done => won
'Opened' is scoped to the CUSTOMER partner's notifications —
not internal CCs — to avoid false positives from sales-ops
viewing the thread.
"""
for rec in self:
if rec.state in ('sale', 'done'):
@@ -209,19 +239,17 @@ class SaleOrder(models.Model):
continue
# state == 'sent'
opened = False
if rec.id:
msgs = self.env['mail.message'].sudo().search([
('model', '=', 'sale.order'),
('res_id', '=', rec.id),
('message_type', '=', 'email'),
], limit=10)
# mail.notification tracks read timestamps
for m in msgs:
if m.notification_ids.filtered(
lambda n: n.is_read
):
opened = True
break
if rec.id and rec.partner_id:
# Look for any read notification on any email message
# of this SO that targeted the customer.
notif_count = self.env['mail.notification'].sudo().search_count([
('mail_message_id.model', '=', 'sale.order'),
('mail_message_id.res_id', '=', rec.id),
('mail_message_id.message_type', '=', 'email'),
('res_partner_id', '=', rec.partner_id.id),
('is_read', '=', True),
])
opened = notif_count > 0
rec.x_fc_email_status = 'opened' if opened else 'sent'
@api.depends('order_line.x_fc_part_catalog_id.part_number')
@@ -254,16 +282,33 @@ class SaleOrder(models.Model):
- sum(refunds.mapped('amount_total'))
)
@api.depends('name')
def _compute_workorder_count(self):
WO = self.env['mrp.workorder'].sudo()
for rec in self:
if not rec.name:
rec.x_fc_workorder_count = 0
continue
rec.x_fc_workorder_count = WO.search_count([
('production_id.origin', '=', rec.name),
('state', 'not in', ('done', 'cancel')),
])
rec.x_fc_workorder_count = 0
names = [so.name for so in self if so.name]
if not names:
return
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names),
('state', 'not in', ('done', 'cancel'))],
['production_id'],
['production_id'],
lazy=False,
)
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
if origin:
totals[origin] = totals.get(origin, 0) + r['__count']
for rec in self:
rec.x_fc_workorder_count = totals.get(rec.name, 0)
def action_view_workorders(self):
self.ensure_one()
@@ -290,21 +335,41 @@ class SaleOrder(models.Model):
string='Files', compute='_compute_nav_counts',
)
@api.depends('invoice_ids', 'picking_ids')
def _compute_nav_counts(self):
NCR = self.env.get('fusion.plating.ncr')
# Invoice + picking counts are cheap (related collections).
for rec in self:
rec.x_fc_invoice_count = len(rec.invoice_ids)
rec.x_fc_picking_count = len(rec.picking_ids)
rec.x_fc_attachment_count = self.env['ir.attachment'].sudo().search_count([
('res_model', '=', 'sale.order'),
('res_id', '=', rec.id),
])
if NCR and 'sale_order_id' in NCR._fields:
rec.x_fc_ncr_count = NCR.sudo().search_count([
('sale_order_id', '=', rec.id),
])
else:
rec.x_fc_ncr_count = 0
# Attachment counts — batched read_group.
ids = self.ids
att_counts = {}
if ids:
rows = self.env['ir.attachment'].sudo().read_group(
[('res_model', '=', 'sale.order'),
('res_id', 'in', ids)],
['res_id'], ['res_id'], lazy=False,
)
att_counts = {r['res_id']: r['__count'] for r in rows}
for rec in self:
rec.x_fc_attachment_count = att_counts.get(rec.id, 0)
# NCR counts — only if the module is installed.
NCR = self.env.get('fusion.plating.ncr')
ncr_counts = {}
if ids and NCR is not None and 'sale_order_id' in NCR._fields:
rows = NCR.sudo().read_group(
[('sale_order_id', 'in', ids)],
['sale_order_id'], ['sale_order_id'], lazy=False,
)
ncr_counts = {
(r['sale_order_id'][0] if r['sale_order_id'] else False):
r['__count']
for r in rows
}
for rec in self:
rec.x_fc_ncr_count = ncr_counts.get(rec.id, 0)
def action_view_invoices(self):
self.ensure_one()
@@ -421,19 +486,23 @@ class SaleOrder(models.Model):
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Simple margin: untaxed total minus rolled-up cost from coating configs."""
"""Simple margin: untaxed total minus rolled-up cost from coating configs.
x_fc_margin_percent is stored as a fraction (0.0 - 1.0) so the
widget='percentage' formats it correctly (a 100% margin reads
as 100%, not 10000%).
"""
for rec in self:
cost = 0.0
for line in rec.order_line:
if line.x_fc_coating_config_id:
# If coating_config has a cost field, use it; otherwise 0.
cost_per_unit = getattr(
line.x_fc_coating_config_id, 'unit_cost', 0.0,
) or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
rec.x_fc_margin_amount = (rec.amount_untaxed or 0) - cost
rec.x_fc_margin_percent = (
(rec.x_fc_margin_amount / rec.amount_untaxed * 100.0)
(rec.x_fc_margin_amount / rec.amount_untaxed)
if rec.amount_untaxed else 0.0
)

View File

@@ -96,15 +96,20 @@
<br/>
<small t-field="line.name"/>
</td>
<td t-field="line.x_fc_coating_config_id"/>
<td class="text-end"
t-field="line.product_uom_qty"/>
<td class="text-end"
t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<td class="text-end"
t-field="line.price_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<td>
<span t-field="line.x_fc_coating_config_id"/>
</td>
<td class="text-end">
<span t-field="line.product_uom_qty"/>
</td>
<td class="text-end">
<span t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</tbody>
<tfoot>
@@ -113,8 +118,10 @@
<strong>Total</strong>
</td>
<td class="text-end">
<strong t-field="doc.amount_total"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<strong>
<span t-field="doc.amount_total"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong>
</td>
</tr>
</tfoot>

View File

@@ -94,9 +94,11 @@ class FpDirectOrderLine(models.TransientModel):
start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
domain="[('parent_id', 'child_of', coating_config_id and coating_config_id.recipe_id.id)]",
domain="[('id', 'child_of', coating_config_id and coating_config_id.recipe_id.id or 0)]",
help='For re-work jobs: pick the recipe step where this job should '
'begin. Skips ancestor steps in the generated work order.',
'begin. Pick a coating first — nodes are scoped to its '
'recipe tree. Skips earlier steps in the generated WO but '
'keeps later siblings and sub-processes.',
)
is_one_off = fields.Boolean(
string='One-off Part',

View File

@@ -235,9 +235,13 @@ class FpDirectOrderWizard(models.TransientModel):
'order_line': [],
}
# 4. One SO line per wizard line
# 4. One SO line per wizard line. Cache resolved parts (post
# rev-bump) so the push-to-defaults pass writes to the right
# catalog entry.
resolved_parts = {} # {wizard_line_id: resolved part record}
for line in self.line_ids:
part = line._get_or_bump_revision()
resolved_parts[line.id] = part
header = '%s - %s Rev %s (x%d)' % (
line.coating_config_id.name,
part.name,
@@ -270,14 +274,14 @@ class FpDirectOrderWizard(models.TransientModel):
so = self.env['sale.order'].create(so_vals)
so.action_confirm()
# 6. Push-to-defaults (C4) — after the part has been resolved /
# revision-bumped, write coating + treatments back onto the part
# catalog entry so the next order inherits the same defaults.
# 6. Push-to-defaults (C4) — uses the resolved part cached
# during the build loop so rev-bumped lines write defaults to
# the NEW revision, not the pre-bump one.
for line in self.line_ids:
if not line.push_to_defaults:
if not line.push_to_defaults or line.is_one_off:
continue
part = line.part_catalog_id
if not part or line.is_one_off:
part = resolved_parts.get(line.id) or line.part_catalog_id
if not part:
continue
part.write({
'x_fc_default_coating_config_id': line.coating_config_id.id or False,