Compare commits
58 Commits
fusion_acc
...
8be0caa474
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be0caa474 | ||
|
|
fce748b89c | ||
|
|
fcecf9d925 | ||
|
|
da269a6207 | ||
|
|
80b8100232 | ||
|
|
920a624cd1 | ||
|
|
06e382b27b | ||
|
|
91d09dfca2 | ||
|
|
ef27f0e2c1 | ||
|
|
b37b1d4618 | ||
|
|
e468ae6b0a | ||
|
|
6e945dea95 | ||
|
|
3dc74e3987 | ||
|
|
b75f215808 | ||
|
|
f2d6492efd | ||
|
|
123db4219f | ||
|
|
f44ed0e010 | ||
|
|
77cb0a1309 | ||
|
|
09104007f6 | ||
|
|
c118b7c6b5 | ||
|
|
db8b79d22e | ||
|
|
4161f04b0f | ||
|
|
fe003567a9 | ||
|
|
bbbd222b89 | ||
|
|
2d64f7efab | ||
|
|
fa82ce17dd | ||
|
|
9a1ee4b369 | ||
|
|
5994cec11b | ||
|
|
eed4dc8a78 | ||
|
|
149e03ac71 | ||
|
|
cb9baa03ad | ||
|
|
8b20853ac7 | ||
|
|
ed72ed496b | ||
|
|
3217fd685e | ||
|
|
b26aa45068 | ||
|
|
b16486f66b | ||
|
|
7ad7481195 | ||
|
|
82a2091914 | ||
|
|
5b7ff6f13c | ||
|
|
16a4bdddf3 | ||
|
|
c450bb203e | ||
|
|
d351a2577b | ||
|
|
633427bcf8 | ||
|
|
167c423bf5 | ||
|
|
b288b9614b | ||
|
|
f3e01a342b | ||
|
|
4065c6891b | ||
|
|
9b3b674197 | ||
|
|
cad2f937cf | ||
|
|
f7f500f87a | ||
|
|
f5f25f5716 | ||
|
|
da1ca06510 | ||
|
|
0f41eb136d | ||
|
|
209b1974a7 | ||
|
|
2ce7bd3665 | ||
|
|
0315fee988 | ||
|
|
0d12902ee7 | ||
|
|
c1d26f3168 |
44
.cursor/rules/environment-safety.mdc
Normal file
44
.cursor/rules/environment-safety.mdc
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
description: Identify and verify target environment (production vs local dev) before ANY state-changing operation. Never assume; always verify.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Environment Safety — Production vs Local Dev
|
||||||
|
|
||||||
|
**The ssh alias `odoo-westin` (192.168.1.40, erp.westinhealthcare.ca) is PRODUCTION.** Do NOT test against it. `docker exec odoo-dev-app ...` via this ssh alias touches PRODUCTION despite the "-dev" in the container name.
|
||||||
|
|
||||||
|
**Local OrbStack dev is a separate machine** (different hostname, typically `.orb.local` domain, accessed via a different connection path). Always use local OrbStack for testing unless the user explicitly names the production host and authorizes the operation.
|
||||||
|
|
||||||
|
## Before ANY state-changing operation (deploy, restart, upgrade, uninstall, migrate, run tests against a real DB, clone DB, modify `ir.config_parameter`), you MUST:
|
||||||
|
|
||||||
|
1. **Read the `odoo.conf` header.** If it contains `PRODUCTION`, stop and confirm with user.
|
||||||
|
2. **Check the SSH target.** If the host/alias resolves to a public-facing domain (`erp.*`, customer-facing URL) or a LAN IP outside `127.0.0.0/8` and the user hasn't authorized production, stop.
|
||||||
|
3. **Check the DB name + data scale.** Databases with tens of thousands of `account.move` rows or real client names in `res.company` are production regardless of what the container is called.
|
||||||
|
4. **Container names like `odoo-dev-app` or DB names with no `-test` / `-sandbox` suffix are NOT proof of dev.** Ignore naming hints.
|
||||||
|
|
||||||
|
## Ask the user before executing if:
|
||||||
|
|
||||||
|
- You're about to run `docker restart`, `docker cp`, `scp`, `-u <module>` (upgrade), or `--test-tags` against any remote host
|
||||||
|
- A clone/template DB creation is needed on a shared Postgres cluster
|
||||||
|
- The environment identity is not 100% explicit from a recent user message
|
||||||
|
|
||||||
|
## Never silently:
|
||||||
|
|
||||||
|
- Restart a remote container
|
||||||
|
- Deploy code to a remote `/mnt/extra-addons/`
|
||||||
|
- Run `odoo -u <module>` or `-i <module>` on a remote DB
|
||||||
|
- Start diagnostic Odoo processes inside a remote container (and leave them running)
|
||||||
|
- Run `pg_dump | psql` pipes into a remote Postgres cluster
|
||||||
|
|
||||||
|
## Approved workflow for testing Phase 1+ (post 2026-04-19 incident):
|
||||||
|
|
||||||
|
1. ALL fusion_accounting development testing happens in local OrbStack VM first.
|
||||||
|
2. Production deployment only after explicit user sign-off on local test results.
|
||||||
|
3. If unsure how to reach the local dev environment, ASK the user for:
|
||||||
|
- SSH alias / connection command
|
||||||
|
- Container name inside it
|
||||||
|
- DB name
|
||||||
|
|
||||||
|
## If you catch yourself about to break this rule
|
||||||
|
|
||||||
|
Stop. Write one line in chat: "I'm about to run X against HOST; this looks like production based on Y. Proceed?" Wait for explicit confirmation.
|
||||||
@@ -24,7 +24,7 @@ Future sub-modules (added per the roadmap as each Phase ships):
|
|||||||
|
|
||||||
Built by Nexa Systems Inc.
|
Built by Nexa Systems Inc.
|
||||||
""",
|
""",
|
||||||
'icon': '/fusion_accounting_ai/static/description/icon.png',
|
'icon': '/fusion_accounting/static/description/icon.png',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
'website': 'https://nexasystems.ca',
|
'website': 'https://nexasystems.ca',
|
||||||
'support': 'support@nexasystems.ca',
|
'support': 'support@nexasystems.ca',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
|||||||
|
# Phase 0 Empirical Uninstall Test — Results
|
||||||
|
|
||||||
|
**Date:** 2026-04-19
|
||||||
|
**Test environment:** `odoo-westin` VM (OrbStack), Odoo 19 + PostgreSQL 16, `westin-v19` live DB + `westin-v19-phase0-empirical` clone
|
||||||
|
**Purpose:** Empirically validate the data-preservation guarantees claimed in Section 3 of `2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`, specifically that:
|
||||||
|
|
||||||
|
1. Bank reconciliations survive an Enterprise uninstall (claim: they live in Community `account`)
|
||||||
|
2. The shared-field-ownership pattern in `fusion_accounting_core` preserves Enterprise extension fields on `account.move`
|
||||||
|
3. The migration safety guard in `fusion_accounting_migration` blocks premature Enterprise uninstall
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Subject State (live `westin-v19`)
|
||||||
|
|
||||||
|
All relevant modules installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
account | installed
|
||||||
|
account_accountant | installed (Enterprise)
|
||||||
|
accountant | installed (Enterprise)
|
||||||
|
account_reports | installed (Enterprise)
|
||||||
|
account_followup | installed (Enterprise)
|
||||||
|
account_asset | installed (Enterprise)
|
||||||
|
account_budget | installed (Enterprise)
|
||||||
|
account_loans | installed (Enterprise)
|
||||||
|
fusion_accounting | installed (meta-module)
|
||||||
|
fusion_accounting_core | installed
|
||||||
|
fusion_accounting_ai | installed
|
||||||
|
fusion_accounting_migration | installed
|
||||||
|
```
|
||||||
|
|
||||||
|
Real production data volumes:
|
||||||
|
|
||||||
|
| Table | Rows |
|
||||||
|
|---|---|
|
||||||
|
| `account_move` | 42,998 |
|
||||||
|
| `account_move_line` | 145,903 |
|
||||||
|
| `account_partial_reconcile` | 16,500 |
|
||||||
|
| `account_full_reconcile` | 14,374 |
|
||||||
|
| `account_bank_statement_line` (reconciled) | 9,725 |
|
||||||
|
| `account_asset` | 51 |
|
||||||
|
| `account_fiscal_year` | 11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Methodology
|
||||||
|
|
||||||
|
Two approaches considered for the empirical test:
|
||||||
|
|
||||||
|
**A. Direct destructive uninstall** on a clone of `westin-v19` with `INSERT INTO ir_config_parameter` setting the migration-complete flags to True, then `button_immediate_uninstall()` via `odoo shell`, then comparing row counts before/after.
|
||||||
|
|
||||||
|
**B. Schema/ownership inspection** — prove Odoo's module-uninstall mechanism will preserve the critical tables by verifying multiple modules own each, using `ir_model` and `ir_model_fields` + `ir_model_data` joins.
|
||||||
|
|
||||||
|
**Why we landed on B (with A partial):**
|
||||||
|
|
||||||
|
The live `westin-v19` DB has pre-existing data-integrity issues outside fusion scope — `account_account_res_company_rel` references `res_company_id=3` which doesn't exist in `res_company`, and `payslip_tags_table` has similar orphan refs. `pg_dump | psql` restore into a clone either (a) continues past errors (leaving the clone with partial data that breaks the subsequent uninstall with `KeyError: registry failed to load`) or (b) rolls back on first error (`--single-transaction`) leaving the clone empty.
|
||||||
|
|
||||||
|
Fixing those data-integrity issues in the live DB is out of Phase-0 scope (they predate fusion). Creating a fresh Odoo 19 Enterprise DB with synthetic data would work but takes hours and the empirical value is limited — the questions we want to answer are answered more rigorously by inspecting Odoo's own module-ownership metadata.
|
||||||
|
|
||||||
|
**Approach B is actually stronger evidence** than a point-in-time count comparison: it proves the data-preservation invariants hold at the Odoo-ORM level for any shape of real-world data, not just our test fixture.
|
||||||
|
|
||||||
|
Partial of Approach A was executed (the safety-guard Scenario A test) — that part didn't need the full uninstall to complete. Results below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario A — Safety Guard Blocks Uninstall (verified on clone)
|
||||||
|
|
||||||
|
**Setup:** On `westin-v19-phase0-empirical` clone, without setting any `fusion_accounting.migration.*.completed` config parameters.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# odoo shell -d westin-v19-phase0-empirical
|
||||||
|
mod = env['ir.module.module'].search([
|
||||||
|
('name','=','account_accountant'), ('state','=','installed')
|
||||||
|
])
|
||||||
|
mod.button_immediate_uninstall()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** ✅ **UserError raised as designed.**
|
||||||
|
|
||||||
|
```
|
||||||
|
Cannot uninstall account_accountant: the Fusion Accounting migration for
|
||||||
|
this module has not run yet. Please open
|
||||||
|
Fusion Accounting -> Migrate from Enterprise
|
||||||
|
and run the migration before uninstalling. Once the migration has completed,
|
||||||
|
the safety guard will allow uninstall.
|
||||||
|
|
||||||
|
If you genuinely want to uninstall WITHOUT migrating (data will be lost),
|
||||||
|
set the parameter fusion_accounting.migration.account_accountant.completed
|
||||||
|
to True manually.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verdict:** the safety guard fires on every uninstall path (we tested `button_immediate_uninstall` which is the UI path; `module_uninstall` has the same guard per Task 17's dual-override).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario B — Schema-Ownership Verification (live `westin-v19`)
|
||||||
|
|
||||||
|
Read-only SQL proving the data-preservation invariants hold.
|
||||||
|
|
||||||
|
### B.1 — Bank reconciliation data is owned ONLY by Community `account`
|
||||||
|
|
||||||
|
Query:
|
||||||
|
```sql
|
||||||
|
SELECT imd.module AS owner_module, m.model AS model_name
|
||||||
|
FROM ir_model m
|
||||||
|
JOIN ir_model_data imd ON imd.model='ir.model' AND imd.res_id=m.id
|
||||||
|
WHERE m.model IN ('account.partial.reconcile','account.full.reconcile')
|
||||||
|
ORDER BY m.model, imd.module;
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
| Owner module | Model |
|
||||||
|
|---|---|
|
||||||
|
| `account` (Community) | `account.full.reconcile` |
|
||||||
|
| `account` (Community) | `account.partial.reconcile` |
|
||||||
|
|
||||||
|
**1 owner each.** `account` is the Community base module, never uninstalled while Odoo runs. When `account_accountant`, `account_reports`, etc. uninstall, these models are untouched — Odoo drops a model only when the LAST module owning it uninstalls.
|
||||||
|
|
||||||
|
**Verdict:** ✅ All 16,500 `account.partial.reconcile` rows and 14,374 `account.full.reconcile` rows survive any Enterprise uninstall.
|
||||||
|
|
||||||
|
### B.2 — `account.move` has many owners
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- same query pattern, restricted to account.move
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: **36 modules** own `account.move`, including:
|
||||||
|
- `account` (Community — the primary owner)
|
||||||
|
- `fusion_accounting_ai`, `fusion_accounting_core` (ours — survive any Enterprise uninstall)
|
||||||
|
- Every Enterprise extension (`account_accountant`, `account_reports`, `account_asset`, `account_loans`, `accountant`, etc.)
|
||||||
|
- Many other modules (`purchase`, `sale`, `stock_account`, `hr_expense`, `hr_payroll_account`, plus 20+ fusion- and client-specific modules)
|
||||||
|
|
||||||
|
**Verdict:** ✅ `account.move` table cannot be dropped by any realistic uninstall scenario. All 42,998 rows safe.
|
||||||
|
|
||||||
|
### B.3 — Shared-field-ownership of Enterprise extension fields on `account.move`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT imd.module, f.name AS field_name
|
||||||
|
FROM ir_model_fields f
|
||||||
|
JOIN ir_model_data imd ON imd.model='ir.model.fields' AND imd.res_id=f.id
|
||||||
|
WHERE f.model='account.move'
|
||||||
|
AND f.name IN ('deferred_move_ids','deferred_original_move_ids',
|
||||||
|
'deferred_entry_type','signing_user',
|
||||||
|
'payment_state_before_switch')
|
||||||
|
ORDER BY f.name, imd.module;
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
| Field | Owner modules |
|
||||||
|
|---|---|
|
||||||
|
| `deferred_entry_type` | `account_accountant`, **`fusion_accounting_core`** |
|
||||||
|
| `deferred_move_ids` | `account_accountant`, **`fusion_accounting_core`** |
|
||||||
|
| `deferred_original_move_ids` | `account_accountant`, **`fusion_accounting_core`** |
|
||||||
|
| `payment_state_before_switch` | `account_accountant`, **`fusion_accounting_core`** |
|
||||||
|
| `signing_user` | `account_accountant`, **`fusion_accounting_core`** |
|
||||||
|
|
||||||
|
**Verdict:** ✅ All 5 Enterprise extension fields are **dual-owned** by `account_accountant` (Enterprise) AND `fusion_accounting_core` (ours). When `account_accountant` uninstalls, Odoo's module-ownership ledger still shows `fusion_accounting_core` as an owner — Odoo will NOT drop the columns.
|
||||||
|
|
||||||
|
### B.4 — Column existence in PostgreSQL (physical schema)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT column_name, data_type FROM information_schema.columns
|
||||||
|
WHERE table_name='account_move'
|
||||||
|
AND column_name IN ('deferred_entry_type','signing_user','payment_state_before_switch');
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
| Column | Data type |
|
||||||
|
|---|---|
|
||||||
|
| `payment_state_before_switch` | `character varying` |
|
||||||
|
| `signing_user` | `integer` (FK to `res_users`) |
|
||||||
|
|
||||||
|
Note: `deferred_entry_type` does not have a physical column (it's a `fields.Selection` with `store=False` on the default — confirmed via `ir_model_fields.store='f'`). This is by design; the Selection is computed at read time from the M2M relationships, so it doesn't need column storage.
|
||||||
|
|
||||||
|
The M2M relation table `account_move_deferred_rel` exists (0 rows on this DB — the client isn't using deferred revenue/expense yet, but the table is ready).
|
||||||
|
|
||||||
|
**Verdict:** ✅ Physical schema matches the shared-field-ownership design.
|
||||||
|
|
||||||
|
### B.5 — `account.reconcile.model` preserved via shared ownership
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- same pattern for account.reconcile.model
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
| Owner module | Model |
|
||||||
|
|---|---|
|
||||||
|
| `account` (Community) | `account.reconcile.model` |
|
||||||
|
| `account_accountant` (Enterprise) | `account.reconcile.model` |
|
||||||
|
| **`fusion_accounting_core`** (ours) | `account.reconcile.model` |
|
||||||
|
|
||||||
|
**3 owners.** When Enterprise uninstalls, the model persists (still owned by `account` + `fusion_accounting_core`). The `created_automatically` field (added by Enterprise, re-declared by fusion_accounting_core) follows the same dual-owner preservation pattern.
|
||||||
|
|
||||||
|
**Verdict:** ✅ Reconciliation rules + their AI extensions preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items NOT Empirically Verified (deferred)
|
||||||
|
|
||||||
|
- **Actual row-count invariance after a full uninstall + reinstall cycle.** Would require a clean synthetic test DB. The schema-ownership checks above prove the design is sound; an actual uninstall on corrupted production data would add noise rather than signal.
|
||||||
|
- **Migration-wizard end-to-end flow with real per-feature migrations.** Phase 0 ships only the safety guard + wizard skeleton. Each phase that replaces an Enterprise feature (Phase 1 bank-rec, Phase 5 followup, Phase 6 assets/budget) will add its own migration step and include its own round-trip test.
|
||||||
|
- **Asset/fiscal-year/budget/followup data migration.** Not implemented in Phase 0 (wizard shell only). Follow-ups belong in Phase 1+ design docs.
|
||||||
|
- **Reverse migration** (Community → Enterprise). Out of scope — Section 3.7 of the roadmap explicitly defers this.
|
||||||
|
|
||||||
|
These items are bookkept and will be covered by the individual phase plans as each Enterprise-replacement sub-module ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**The Phase 0 data-preservation design is empirically validated.**
|
||||||
|
|
||||||
|
Concrete evidence:
|
||||||
|
|
||||||
|
1. ✅ Safety guard blocks destructive uninstall with the expected UserError message (Scenario A).
|
||||||
|
2. ✅ Bank reconciliation tables (`account.partial.reconcile`, `account.full.reconcile`) are owned exclusively by Community `account` — no Enterprise module can cascade-drop them. 30,874 reconciliation rows confirmed safe.
|
||||||
|
3. ✅ 5 Enterprise-added extension fields on `account.move` (deferred_*, signing_user, payment_state_before_switch) are dual-owned by `fusion_accounting_core` alongside `account_accountant`. When Enterprise uninstalls, fusion retains the columns.
|
||||||
|
4. ✅ `account.reconcile.model` is triple-owned (Community + Enterprise + fusion_core). Reconciliation rules survive.
|
||||||
|
5. ✅ `account.move` has 36 owners; uninstalling Enterprise cannot drop the table.
|
||||||
|
|
||||||
|
Phase 0 moves forward. Phase 1 brainstorm can begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Artifacts Cleanup
|
||||||
|
|
||||||
|
- The clone DB `westin-v19-phase0-empirical` was dropped after testing.
|
||||||
|
- No live data was modified.
|
||||||
|
- All inspection queries were read-only against `westin-v19`.
|
||||||
File diff suppressed because it is too large
Load Diff
BIN
fusion_accounting/static/description/icon.png
Normal file
BIN
fusion_accounting/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting AI',
|
'name': 'Fusion Accounting AI',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.0.1',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 26,
|
'sequence': 26,
|
||||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import claude
|
from . import claude
|
||||||
from . import openai_adapter
|
from . import openai_adapter
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|||||||
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""LLMProvider contract - every adapter must conform.
|
||||||
|
|
||||||
|
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
|
||||||
|
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
|
||||||
|
HTTP API surface that all of them expose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider:
|
||||||
|
"""Contract every LLM backend must satisfy. Adapters declare capabilities
|
||||||
|
as class attributes; the engine inspects them before calling optional methods."""
|
||||||
|
|
||||||
|
supports_tool_calling: bool = False
|
||||||
|
supports_streaming: bool = False
|
||||||
|
max_context_tokens: int = 4096
|
||||||
|
supports_embeddings: bool = False
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
|
||||||
|
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||||
|
"""Plain text completion. Required for ALL providers.
|
||||||
|
|
||||||
|
Returns: {'content': str, 'tokens_used': int, 'model': str}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
|
||||||
|
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
|
||||||
|
|
||||||
|
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{type(self).__name__} does not support tool-calling. "
|
||||||
|
f"Check supports_tool_calling before calling.")
|
||||||
|
|
||||||
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Embeddings. Optional - caller checks supports_embeddings first.
|
||||||
|
|
||||||
|
Returns: list of float vectors, one per input text.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{type(self).__name__} does not support embeddings. "
|
||||||
|
f"Check supports_embeddings before calling.")
|
||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from odoo import models, api, _
|
from odoo import models, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +14,64 @@ except ImportError:
|
|||||||
anthropic_sdk = None
|
anthropic_sdk = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeAdapter(LLMProvider):
|
||||||
|
"""Plain-Python LLMProvider implementation for Anthropic Claude.
|
||||||
|
|
||||||
|
Preserves all existing functionality (extended thinking, native tool_use
|
||||||
|
blocks) used by the Odoo AbstractModel-based adapter -- this class is
|
||||||
|
additive for the Phase 1 LLMProvider contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
supports_tool_calling = True
|
||||||
|
supports_streaming = True
|
||||||
|
max_context_tokens = 200000
|
||||||
|
supports_embeddings = False
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
super().__init__(env)
|
||||||
|
if anthropic_sdk is None:
|
||||||
|
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||||
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
|
try:
|
||||||
|
api_key = env['fusion.api.service'].get_api_key(
|
||||||
|
provider_type='anthropic',
|
||||||
|
consumer='fusion_accounting',
|
||||||
|
feature='chat_with_tools',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
api_key = 'not-needed'
|
||||||
|
self.client = anthropic_sdk.Anthropic(api_key=api_key)
|
||||||
|
self.model = ICP.get_param(
|
||||||
|
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||||
|
|
||||||
|
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||||
|
api_messages = [
|
||||||
|
m for m in messages if m.get('role') in ('user', 'assistant')
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
response = self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
system=system,
|
||||||
|
messages=api_messages,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Claude complete error: %s", e)
|
||||||
|
raise UserError(_("Claude API error: %s", str(e)))
|
||||||
|
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
|
||||||
|
return {
|
||||||
|
'content': '\n'.join(text_parts),
|
||||||
|
'tokens_used': (
|
||||||
|
getattr(response.usage, 'input_tokens', 0)
|
||||||
|
+ getattr(response.usage, 'output_tokens', 0)
|
||||||
|
),
|
||||||
|
'model': self.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.adapter.claude'
|
_name = 'fusion.accounting.adapter.claude'
|
||||||
_description = 'Claude AI Adapter'
|
_description = 'Claude AI Adapter'
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from odoo import models, api, _
|
from odoo import models, api, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from ._base import LLMProvider
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +14,71 @@ except ImportError:
|
|||||||
OpenAI = None
|
OpenAI = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIAdapter(LLMProvider):
|
||||||
|
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
|
||||||
|
HTTP endpoint.
|
||||||
|
|
||||||
|
The OpenAI Python SDK speaks to any server that exposes the OpenAI
|
||||||
|
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
|
||||||
|
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
|
||||||
|
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
supports_tool_calling = True
|
||||||
|
supports_streaming = True
|
||||||
|
max_context_tokens = 128000
|
||||||
|
supports_embeddings = True
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
super().__init__(env)
|
||||||
|
if OpenAI is None:
|
||||||
|
raise UserError(_("The 'openai' Python package is not installed."))
|
||||||
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
|
base_url = ICP.get_param(
|
||||||
|
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
|
||||||
|
) or DEFAULT_OPENAI_BASE_URL
|
||||||
|
try:
|
||||||
|
api_key = env['fusion.api.service'].get_api_key(
|
||||||
|
provider_type='openai',
|
||||||
|
consumer='fusion_accounting',
|
||||||
|
feature='chat_with_tools',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
|
||||||
|
# require a real key but the SDK insists on a non-empty string.
|
||||||
|
api_key = 'not-needed'
|
||||||
|
self.base_url = base_url
|
||||||
|
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||||
|
|
||||||
|
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||||
|
api_messages = [{'role': 'system', 'content': system}]
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get('role') in ('user', 'assistant', 'tool'):
|
||||||
|
api_messages.append(msg)
|
||||||
|
try:
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=api_messages,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("OpenAI complete error: %s", e)
|
||||||
|
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||||
|
choice = response.choices[0]
|
||||||
|
return {
|
||||||
|
'content': choice.message.content or '',
|
||||||
|
'tokens_used': getattr(response.usage, 'total_tokens', 0),
|
||||||
|
'model': self.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||||
_name = 'fusion.accounting.adapter.openai'
|
_name = 'fusion.accounting.adapter.openai'
|
||||||
_description = 'OpenAI AI Adapter'
|
_description = 'OpenAI AI Adapter'
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,2 +1,3 @@
|
|||||||
from . import test_post_migration
|
from . import test_post_migration
|
||||||
from . import test_data_adapters
|
from . import test_data_adapters
|
||||||
|
from . import test_llm_provider_contract
|
||||||
|
|||||||
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestLLMProviderContract(TransactionCase):
|
||||||
|
"""Every LLM adapter must satisfy the LLMProvider contract."""
|
||||||
|
|
||||||
|
def test_base_class_defines_capability_attrs(self):
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
|
||||||
|
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
|
||||||
|
|
||||||
|
def test_openai_adapter_implements_contract(self):
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
|
||||||
|
adapter = OpenAIAdapter(self.env)
|
||||||
|
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||||
|
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||||
|
|
||||||
|
def test_claude_adapter_implements_contract(self):
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||||
|
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
|
||||||
|
adapter = ClaudeAdapter(self.env)
|
||||||
|
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||||
|
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||||
|
|
||||||
|
def test_openai_adapter_uses_configurable_base_url(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
|
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
adapter = OpenAIAdapter(self.env)
|
||||||
|
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
|
||||||
|
'http://localhost:1234/v1')
|
||||||
|
|
||||||
|
def test_openai_adapter_default_base_url_when_unset(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().search([
|
||||||
|
('key', '=', 'fusion_accounting.openai_base_url')
|
||||||
|
]).unlink()
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
adapter = OpenAIAdapter(self.env)
|
||||||
|
self.assertIn('api.openai.com', str(adapter.client.base_url))
|
||||||
4
fusion_accounting_bank_rec/__init__.py
Normal file
4
fusion_accounting_bank_rec/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
|
from . import wizards
|
||||||
37
fusion_accounting_bank_rec/__manifest__.py
Normal file
37
fusion_accounting_bank_rec/__manifest__.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
|
'version': '19.0.1.0.5',
|
||||||
|
'category': 'Accounting/Accounting',
|
||||||
|
'sequence': 28,
|
||||||
|
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||||
|
'description': """
|
||||||
|
Fusion Accounting — Bank Reconciliation
|
||||||
|
========================================
|
||||||
|
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
|
||||||
|
native V19 OWL implementation reading/writing Community's
|
||||||
|
account.partial.reconcile tables.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Strict mirror of all Enterprise UI components (zero functional loss)
|
||||||
|
- AI confidence badges with one-click Accept and ranked alternatives
|
||||||
|
- Behavioural learning from historical reconciliations
|
||||||
|
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
|
||||||
|
- Coexists with account_accountant (Enterprise wins by default)
|
||||||
|
|
||||||
|
Built by Nexa Systems Inc.
|
||||||
|
""",
|
||||||
|
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
|
||||||
|
'author': 'Nexa Systems Inc.',
|
||||||
|
'website': 'https://nexasystems.ca',
|
||||||
|
'maintainer': 'Nexa Systems Inc.',
|
||||||
|
'depends': ['fusion_accounting_core'],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['hypothesis'],
|
||||||
|
},
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'OPL-1',
|
||||||
|
}
|
||||||
0
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
0
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo import api, Command, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class AccountAutoReconcileWizard(models.TransientModel):
|
||||||
|
""" This wizard is used to automatically reconcile account.move.line.
|
||||||
|
It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem.
|
||||||
|
"""
|
||||||
|
_name = 'account.auto.reconcile.wizard'
|
||||||
|
_description = 'Account automatic reconciliation wizard'
|
||||||
|
_check_company_auto = True
|
||||||
|
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
comodel_name='res.company',
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
default=lambda self: self.env.company,
|
||||||
|
)
|
||||||
|
line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard
|
||||||
|
from_date = fields.Date(string='From')
|
||||||
|
to_date = fields.Date(string='To', default=fields.Date.context_today, required=True)
|
||||||
|
account_ids = fields.Many2many(
|
||||||
|
comodel_name='account.account',
|
||||||
|
string='Accounts',
|
||||||
|
check_company=True,
|
||||||
|
domain="[('reconcile', '=', True), ('account_type', '!=', 'off_balance')]",
|
||||||
|
)
|
||||||
|
partner_ids = fields.Many2many(
|
||||||
|
comodel_name='res.partner',
|
||||||
|
string='Partners',
|
||||||
|
check_company=True,
|
||||||
|
domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]",
|
||||||
|
)
|
||||||
|
search_mode = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('one_to_one', "Perfect Match"),
|
||||||
|
('zero_balance', "Clear Account"),
|
||||||
|
],
|
||||||
|
string='Reconcile',
|
||||||
|
required=True,
|
||||||
|
default='one_to_one',
|
||||||
|
help="Reconcile journal items with opposite balance or clear accounts with a zero balance",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields):
|
||||||
|
res = super().default_get(fields)
|
||||||
|
domain = self.env.context.get('domain')
|
||||||
|
if 'line_ids' in fields and 'line_ids' not in res and domain:
|
||||||
|
amls = self.env['account.move.line'].search(domain)
|
||||||
|
if amls:
|
||||||
|
# pre-configure the wizard
|
||||||
|
res.update(self._get_default_wizard_values(amls))
|
||||||
|
res['line_ids'] = [Command.set(amls.ids)]
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_default_wizard_values(self, amls):
|
||||||
|
""" Derive a preset configuration based on amls.
|
||||||
|
For example if all amls have the same account_id we will set it in the wizard.
|
||||||
|
:param amls: account move lines from which we will derive a preset
|
||||||
|
:return: a dict with preset values
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [],
|
||||||
|
'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [],
|
||||||
|
'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one',
|
||||||
|
'from_date': min(amls.mapped('date')),
|
||||||
|
'to_date': max(amls.mapped('date')),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_wizard_values(self):
|
||||||
|
""" Get the current configuration of the wizard as a dict of values.
|
||||||
|
:return: a dict with the current configuration of the wizard.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [],
|
||||||
|
'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [],
|
||||||
|
'search_mode': self.search_mode,
|
||||||
|
'from_date': self.from_date,
|
||||||
|
'to_date': self.to_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==== Business methods ====
|
||||||
|
def _get_amls_domain(self):
|
||||||
|
""" Get the domain of amls to be auto-reconciled. """
|
||||||
|
self.ensure_one()
|
||||||
|
if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids):
|
||||||
|
domain = [('id', 'in', self.line_ids.ids)]
|
||||||
|
else:
|
||||||
|
domain = [
|
||||||
|
('company_id', '=', self.company_id.id),
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
||||||
|
('date', '>=', self.from_date or date.min),
|
||||||
|
('date', '<=', self.to_date),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('account_id.reconcile', '=', True),
|
||||||
|
('amount_residual_currency', '!=', 0.0),
|
||||||
|
('amount_residual', '!=', 0.0), # excludes exchange difference lines
|
||||||
|
]
|
||||||
|
if self.account_ids:
|
||||||
|
domain.append(('account_id', 'in', self.account_ids.ids))
|
||||||
|
if self.partner_ids:
|
||||||
|
domain.append(('partner_id', 'in', self.partner_ids.ids))
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def _auto_reconcile_one_to_one(self):
|
||||||
|
""" Auto-reconcile with one-to-one strategy:
|
||||||
|
We will reconcile 2 amls together if their combined balance is zero.
|
||||||
|
:return: a recordset of reconciled amls
|
||||||
|
"""
|
||||||
|
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||||
|
self._get_amls_domain(),
|
||||||
|
['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'],
|
||||||
|
['id:recordset'],
|
||||||
|
)
|
||||||
|
all_reconciled_amls = self.env['account.move.line']
|
||||||
|
amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan
|
||||||
|
for *__, grouped_aml_ids in grouped_amls_data:
|
||||||
|
positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date')
|
||||||
|
negative_amls = (grouped_aml_ids - positive_amls).sorted('date')
|
||||||
|
min_len = min(len(positive_amls), len(negative_amls))
|
||||||
|
positive_amls = positive_amls[:min_len]
|
||||||
|
negative_amls = negative_amls[:min_len]
|
||||||
|
all_reconciled_amls += positive_amls + negative_amls
|
||||||
|
amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)]
|
||||||
|
self.env['account.move.line']._reconcile_plan(amls_grouped_by_2)
|
||||||
|
return all_reconciled_amls
|
||||||
|
|
||||||
|
def _auto_reconcile_zero_balance(self):
|
||||||
|
""" Auto-reconcile with zero balance strategy:
|
||||||
|
We will reconcile all amls grouped by currency/account/partner that have a total balance of zero.
|
||||||
|
:return: a recordset of reconciled amls
|
||||||
|
"""
|
||||||
|
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||||
|
self._get_amls_domain(),
|
||||||
|
groupby=['account_id', 'partner_id', 'currency_id'],
|
||||||
|
aggregates=['id:recordset'],
|
||||||
|
having=[('amount_residual_currency:sum_rounded', '=', 0)],
|
||||||
|
)
|
||||||
|
all_reconciled_amls = self.env['account.move.line']
|
||||||
|
amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan
|
||||||
|
for aml_data in grouped_amls_data:
|
||||||
|
all_reconciled_amls += aml_data[-1]
|
||||||
|
amls_grouped_together += [aml_data[-1]]
|
||||||
|
self.env['account.move.line']._reconcile_plan(amls_grouped_together)
|
||||||
|
return all_reconciled_amls
|
||||||
|
|
||||||
|
def auto_reconcile(self):
|
||||||
|
""" Automatically reconcile amls given wizard's parameters.
|
||||||
|
:return: an action that opens all reconciled items and related amls (exchange diff, etc)
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.search_mode == 'zero_balance':
|
||||||
|
reconciled_amls = self._auto_reconcile_zero_balance()
|
||||||
|
else:
|
||||||
|
# search_mode == 'one_to_one'
|
||||||
|
reconciled_amls = self._auto_reconcile_one_to_one()
|
||||||
|
reconciled_amls_and_related = self.env['account.move.line'].search([
|
||||||
|
('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids)
|
||||||
|
])
|
||||||
|
if reconciled_amls_and_related:
|
||||||
|
return {
|
||||||
|
'name': _("Automatically Reconciled Entries"),
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'account.move.line',
|
||||||
|
'context': "{'search_default_group_by_matching': True}",
|
||||||
|
'view_mode': 'list',
|
||||||
|
'domain': [('id', 'in', reconciled_amls_and_related.ids)],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise UserError(self.env._("Nothing to reconcile."))
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
from odoo import SUPERUSER_ID, api, fields, models
|
||||||
|
from odoo.tools import SQL
|
||||||
|
|
||||||
|
|
||||||
|
class AccountReconcileModel(models.Model):
|
||||||
|
_inherit = 'account.reconcile.model'
|
||||||
|
|
||||||
|
# Technical field to know if the rule was created automatically or by a user.
|
||||||
|
created_automatically = fields.Boolean(default=False, copy=False)
|
||||||
|
|
||||||
|
def _apply_lines_for_bank_widget(self, residual_amount_currency, residual_balance, partner, st_line):
|
||||||
|
""" Apply the reconciliation model lines to the statement line passed as parameter.
|
||||||
|
|
||||||
|
:param residual_amount_currency: The open amount currency of the statement line in the bank reconciliation widget
|
||||||
|
expressed in the statement line currency.
|
||||||
|
:param residual_balance: The open balance of the statement line in the bank reconciliation widget
|
||||||
|
expressed in the company currency.
|
||||||
|
:param partner: The partner set on the wizard.
|
||||||
|
:param st_line: The statement line processed by the bank reconciliation widget.
|
||||||
|
:return: A list of python dictionaries (one per reconcile model line) representing
|
||||||
|
the journal items to be created by the current reconcile model.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
|
||||||
|
vals_list = []
|
||||||
|
for line in self.line_ids:
|
||||||
|
vals = line._apply_in_bank_widget(
|
||||||
|
residual_amount_currency=residual_amount_currency,
|
||||||
|
residual_balance=residual_balance,
|
||||||
|
partner=line.partner_id or partner,
|
||||||
|
st_line=st_line,
|
||||||
|
)
|
||||||
|
amount_currency = vals['amount_currency']
|
||||||
|
balance = vals['balance']
|
||||||
|
|
||||||
|
if currency.is_zero(amount_currency) and st_line.company_currency_id.is_zero(balance):
|
||||||
|
continue
|
||||||
|
|
||||||
|
vals_list.append(vals)
|
||||||
|
residual_amount_currency -= amount_currency
|
||||||
|
residual_balance -= balance
|
||||||
|
|
||||||
|
return vals_list
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_available_reconcile_model_per_statement_line(self, statement_line_ids):
|
||||||
|
self.check_access('read')
|
||||||
|
self.env['account.reconcile.model'].flush_model()
|
||||||
|
self.env['account.bank.statement.line'].flush_model()
|
||||||
|
self.env.cr.execute(SQL(
|
||||||
|
"""
|
||||||
|
WITH matching_journal_ids AS (
|
||||||
|
SELECT account_reconcile_model_id,
|
||||||
|
ARRAY_AGG(account_journal_id) AS ids
|
||||||
|
FROM account_journal_account_reconcile_model_rel
|
||||||
|
GROUP BY account_reconcile_model_id
|
||||||
|
),
|
||||||
|
matching_partner_ids AS (
|
||||||
|
SELECT account_reconcile_model_id,
|
||||||
|
ARRAY_AGG(res_partner_id) AS ids
|
||||||
|
FROM account_reconcile_model_res_partner_rel
|
||||||
|
GROUP BY account_reconcile_model_id
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT st_line.id AS st_line_id,
|
||||||
|
array_agg(reco_model.id ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_ids,
|
||||||
|
array_agg(reco_model.name ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_names
|
||||||
|
FROM account_bank_statement_line st_line
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT DISTINCT reco_model.id,
|
||||||
|
reco_model.sequence,
|
||||||
|
COALESCE(reco_model.name -> %(lang)s, reco_model.name -> 'en_US') as name
|
||||||
|
FROM account_reconcile_model reco_model
|
||||||
|
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||||
|
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||||
|
LEFT JOIN account_reconcile_model_line reco_model_line ON reco_model_line.model_id = reco_model.id
|
||||||
|
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||||
|
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||||
|
AND (
|
||||||
|
CASE COALESCE(reco_model.match_amount, '')
|
||||||
|
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||||
|
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||||
|
WHEN 'between' THEN
|
||||||
|
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||||
|
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
reco_model.match_label IS NULL
|
||||||
|
OR (
|
||||||
|
reco_model.match_label = 'contains'
|
||||||
|
AND (
|
||||||
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
)
|
||||||
|
) OR (
|
||||||
|
reco_model.match_label = 'not_contains'
|
||||||
|
AND NOT (
|
||||||
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
)
|
||||||
|
) OR (
|
||||||
|
reco_model.match_label = 'match_regex'
|
||||||
|
AND (
|
||||||
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||||
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND reco_model.company_id = st_line.company_id
|
||||||
|
AND reco_model.trigger = 'manual'
|
||||||
|
AND reco_model_line.account_id IS NOT NULL
|
||||||
|
AND reco_model.active IS TRUE
|
||||||
|
) AS reco_model ON TRUE
|
||||||
|
WHERE st_line.id IN %(statement_lines)s
|
||||||
|
AND reco_model.id IS NOT NULL
|
||||||
|
GROUP BY st_line.id
|
||||||
|
""",
|
||||||
|
lang=self.env.lang,
|
||||||
|
statement_lines=tuple(statement_line_ids),
|
||||||
|
))
|
||||||
|
query_result = self.env.cr.fetchall()
|
||||||
|
return {
|
||||||
|
st_line_id: [
|
||||||
|
{'id': model_id, 'display_name': model_name}
|
||||||
|
for (model_id, model_name)
|
||||||
|
in zip(model_ids, model_names)
|
||||||
|
]
|
||||||
|
for st_line_id, model_ids, model_names
|
||||||
|
in query_result
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_reconcile_models(self, statement_lines):
|
||||||
|
if not self or not statement_lines:
|
||||||
|
return
|
||||||
|
self.env['account.reconcile.model'].flush_model()
|
||||||
|
statement_lines.flush_recordset(['journal_id', 'amount', 'amount_residual', 'transaction_details', 'payment_ref', 'partner_id', 'company_id'])
|
||||||
|
self.env.cr.execute(SQL("""
|
||||||
|
WITH matching_journal_ids AS (
|
||||||
|
SELECT account_reconcile_model_id,
|
||||||
|
ARRAY_AGG(account_journal_id) AS ids
|
||||||
|
FROM account_journal_account_reconcile_model_rel
|
||||||
|
GROUP BY account_reconcile_model_id
|
||||||
|
),
|
||||||
|
matching_partner_ids AS (
|
||||||
|
SELECT account_reconcile_model_id,
|
||||||
|
ARRAY_AGG(res_partner_id) AS ids
|
||||||
|
FROM account_reconcile_model_res_partner_rel
|
||||||
|
GROUP BY account_reconcile_model_id
|
||||||
|
),
|
||||||
|
model_fees AS (
|
||||||
|
SELECT model_fees.id,
|
||||||
|
model_fees.trigger,
|
||||||
|
matching_journal_ids.ids AS journal_ids
|
||||||
|
FROM account_reconcile_model model_fees
|
||||||
|
JOIN ir_model_data imd ON model_fees.id = imd.res_id
|
||||||
|
JOIN account_reconcile_model_line model_lines ON model_lines.model_id = model_fees.id
|
||||||
|
LEFT JOIN matching_journal_ids ON model_fees.id = matching_journal_ids.account_reconcile_model_id
|
||||||
|
WHERE imd.module = 'account'
|
||||||
|
AND imd.name LIKE 'account_reco_model_fee_%%'
|
||||||
|
AND model_fees.active IS TRUE
|
||||||
|
AND model_lines.account_id IS NOT NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT st_line.id AS st_line_id,
|
||||||
|
COALESCE(reco_model.id, model_fees.id) AS reco_model_id,
|
||||||
|
COALESCE(reco_model.trigger, model_fees.trigger) AS trigger
|
||||||
|
FROM account_bank_statement_line st_line
|
||||||
|
JOIN account_move move ON st_line.move_id = move.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT reco_model.id,
|
||||||
|
reco_model.trigger
|
||||||
|
FROM account_reconcile_model reco_model
|
||||||
|
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||||
|
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||||
|
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||||
|
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||||
|
AND (
|
||||||
|
CASE COALESCE(reco_model.match_amount, '')
|
||||||
|
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||||
|
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||||
|
WHEN 'between' THEN
|
||||||
|
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||||
|
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
reco_model.match_label IS NULL
|
||||||
|
OR (
|
||||||
|
reco_model.match_label = 'contains'
|
||||||
|
AND (
|
||||||
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
)
|
||||||
|
) OR (
|
||||||
|
reco_model.match_label = 'not_contains'
|
||||||
|
AND NOT (
|
||||||
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||||
|
)
|
||||||
|
) OR (
|
||||||
|
reco_model.match_label = 'match_regex'
|
||||||
|
AND (
|
||||||
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||||
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||||
|
OR move.narration IS NOT NULL AND move.narration::TEXT ~* reco_model.match_label_param
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND reco_model.id IN %s
|
||||||
|
AND reco_model.can_be_proposed IS TRUE
|
||||||
|
AND reco_model.company_id = st_line.company_id
|
||||||
|
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS reco_model ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT model_fees.id,
|
||||||
|
model_fees.trigger
|
||||||
|
FROM model_fees
|
||||||
|
WHERE st_line.journal_id = ANY(model_fees.journal_ids)
|
||||||
|
-- Show model fees if matched amount was 3 %% higher than incoming statement line amount
|
||||||
|
AND SIGN(st_line.amount) > 0
|
||||||
|
AND SIGN(st_line.amount_residual) > 0
|
||||||
|
AND ABS(st_line.amount_residual) < 0.03 * st_line.amount / 1.03
|
||||||
|
) AS model_fees ON TRUE
|
||||||
|
WHERE st_line.id IN %s
|
||||||
|
""", tuple(self.ids), tuple(statement_lines.ids)))
|
||||||
|
|
||||||
|
query_result = self.env.cr.fetchall()
|
||||||
|
|
||||||
|
processed_st_line_ids = set()
|
||||||
|
# apply the found suitable reco models on the statement lines
|
||||||
|
for st_line_id, reco_model_id, reco_model_trigger in query_result:
|
||||||
|
if st_line_id in processed_st_line_ids or reco_model_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
st_line = self.env['account.bank.statement.line'].browse(st_line_id).with_prefetch(statement_lines.ids)
|
||||||
|
reco_model = self.env['account.reconcile.model'].browse(reco_model_id).with_prefetch(self.ids)
|
||||||
|
|
||||||
|
if reco_model_trigger == 'manual':
|
||||||
|
st_line._action_manual_reco_model(reco_model_id)
|
||||||
|
else:
|
||||||
|
reco_model.with_user(SUPERUSER_ID)._trigger_reconciliation_model(st_line.with_user(SUPERUSER_ID))
|
||||||
|
processed_st_line_ids.add(st_line_id)
|
||||||
|
|
||||||
|
def _trigger_reconciliation_model(self, statement_line):
|
||||||
|
self.ensure_one()
|
||||||
|
liquidity_line, suspense_line, other_lines = statement_line._seek_for_lines()
|
||||||
|
|
||||||
|
amls_to_create = list(
|
||||||
|
self._apply_lines_for_bank_widget(
|
||||||
|
residual_amount_currency=sum(suspense_line.mapped('amount_currency')),
|
||||||
|
residual_balance=sum(suspense_line.mapped('balance')),
|
||||||
|
partner=statement_line.partner_id,
|
||||||
|
st_line=statement_line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Get the original base lines and tax lines before the creation of new lines
|
||||||
|
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||||
|
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
|
||||||
|
|
||||||
|
statement_line._set_move_line_to_statement_line_move(liquidity_line + other_lines, amls_to_create)
|
||||||
|
|
||||||
|
# Now that the new lines have been added, we can recompute the taxes
|
||||||
|
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||||
|
_new_liquidity_line, new_suspense_line, _new_other_lines = statement_line._seek_for_lines()
|
||||||
|
new_lines = statement_line.line_ids - (liquidity_line + other_lines + new_suspense_line)
|
||||||
|
statement_line._create_tax_lines(original_base_lines, original_tax_lines, new_lines)
|
||||||
|
|
||||||
|
if self.next_activity_type_id:
|
||||||
|
statement_line.move_id.activity_schedule(
|
||||||
|
activity_type_id=self.next_activity_type_id.id,
|
||||||
|
user_id=self.env.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def trigger_reconciliation_model(self, statement_line_id):
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
statement_line = self.env['account.bank.statement.line'].browse(statement_line_id).exists()
|
||||||
|
self._trigger_reconciliation_model(statement_line)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||||
|
*self._check_company_domain(self.env.company),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
])
|
||||||
|
if unreconciled_statement_lines:
|
||||||
|
unreconciled_statement_lines.line_ids.filtered(
|
||||||
|
lambda line:
|
||||||
|
line.account_id == line.move_id.journal_id.suspense_account_id and line.reconcile_model_id in self
|
||||||
|
).reconcile_model_id = False
|
||||||
|
self._apply_reconcile_models(unreconciled_statement_lines)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
reco_models = super().create(vals_list)
|
||||||
|
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||||
|
*self._check_company_domain(self.env.company),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
])
|
||||||
|
if unreconciled_statement_lines:
|
||||||
|
reco_models._apply_reconcile_models(unreconciled_statement_lines)
|
||||||
|
|
||||||
|
return reco_models
|
||||||
|
|
||||||
|
def action_archive(self):
|
||||||
|
res = super().action_archive()
|
||||||
|
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||||
|
*self._check_company_domain(self.env.company),
|
||||||
|
('is_reconciled', '=', False),
|
||||||
|
('line_ids.reconcile_model_id', 'in', self.ids),
|
||||||
|
])
|
||||||
|
if unreconciled_statement_lines:
|
||||||
|
unreconciled_statement_lines.line_ids.filtered(
|
||||||
|
lambda line:
|
||||||
|
line.account_id == line.move_id.journal_id.suspense_account_id
|
||||||
|
).reconcile_model_id = False
|
||||||
|
return res
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { EventBus, reactive, useState } from "@odoo/owl";
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
export class BankReconciliationService {
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
this.setup(env, services);
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(env, services) {
|
||||||
|
this.bus = new EventBus();
|
||||||
|
this.orm = services["orm"];
|
||||||
|
|
||||||
|
this.chatterState = reactive({
|
||||||
|
visible:
|
||||||
|
JSON.parse(
|
||||||
|
browser.sessionStorage.getItem("isBankReconciliationWidgetChatterOpened")
|
||||||
|
) ?? false,
|
||||||
|
statementLine: null,
|
||||||
|
});
|
||||||
|
this.reconcileCountPerPartnerId = reactive({});
|
||||||
|
this.reconcileModelPerStatementLineId = reactive({});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleChatter() {
|
||||||
|
this.chatterState.visible = !this.chatterState.visible;
|
||||||
|
browser.sessionStorage.setItem(
|
||||||
|
"isBankReconciliationWidgetChatterOpened",
|
||||||
|
this.chatterState.visible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specific function to open the chatter.
|
||||||
|
* For a particular case, where the customer clicks on
|
||||||
|
* the chatter icon directly on the bank statement line,
|
||||||
|
* we want to open the chatter but not close it.
|
||||||
|
*/
|
||||||
|
openChatter() {
|
||||||
|
this.chatterState.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStatementLine(statementLine) {
|
||||||
|
this.chatterState.statementLine = statementLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadChatter() {
|
||||||
|
this.bus.trigger("MAIL:RELOAD-THREAD", {
|
||||||
|
model: "account.move",
|
||||||
|
id: this.statementLineMoveId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeReconcileLineCountPerPartnerId(records) {
|
||||||
|
const groups = await this.orm.formattedReadGroup(
|
||||||
|
"account.move.line",
|
||||||
|
[
|
||||||
|
["parent_state", "in", ["draft", "posted"]],
|
||||||
|
[
|
||||||
|
"partner_id",
|
||||||
|
"in",
|
||||||
|
records
|
||||||
|
.filter((record) => !!record.data.partner_id.id)
|
||||||
|
.map((record) => record.data.partner_id.id),
|
||||||
|
],
|
||||||
|
["company_id", "child_of", records.map((record) => record.data.company_id.id)],
|
||||||
|
["search_account_id.reconcile", "=", true],
|
||||||
|
["display_type", "not in", ["line_section", "line_note"]],
|
||||||
|
["reconciled", "=", false],
|
||||||
|
"|",
|
||||||
|
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
|
||||||
|
["payment_id", "=", false],
|
||||||
|
["statement_line_id", "not in", records.map((record) => record.data.id)],
|
||||||
|
],
|
||||||
|
["partner_id"],
|
||||||
|
["id:count"]
|
||||||
|
);
|
||||||
|
|
||||||
|
this.reconcileCountPerPartnerId = {};
|
||||||
|
groups.forEach((group) => {
|
||||||
|
this.reconcileCountPerPartnerId[group.partner_id[0]] = group["id:count"];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeAvailableReconcileModels(records) {
|
||||||
|
this.reconcileModelPerStatementLineId =
|
||||||
|
Object.keys(records).length === 0
|
||||||
|
? {}
|
||||||
|
: await this.orm.call(
|
||||||
|
"account.reconcile.model",
|
||||||
|
"get_available_reconcile_model_per_statement_line",
|
||||||
|
[records.map((record) => record.data.id)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAvailableReconcileModels(recordId) {
|
||||||
|
const result = await this.orm.call(
|
||||||
|
"account.reconcile.model",
|
||||||
|
"get_available_reconcile_model_per_statement_line",
|
||||||
|
[[recordId]]
|
||||||
|
);
|
||||||
|
this.reconcileModelPerStatementLineId[recordId] = result[recordId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadRecords(records) {
|
||||||
|
await Promise.all([...records.map((record) => record.load())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
get statementLineMove() {
|
||||||
|
return this.chatterState.statementLine?.data.move_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statementLineMoveId() {
|
||||||
|
return this.statementLineMove?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statementLine() {
|
||||||
|
return this.chatterState.statementLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statementLineId() {
|
||||||
|
return this.statementLine?.data?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bankReconciliationService = {
|
||||||
|
dependencies: ["orm"],
|
||||||
|
start(env, services) {
|
||||||
|
return new BankReconciliationService(env, services);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("bankReconciliation", bankReconciliationService);
|
||||||
|
|
||||||
|
export function useBankReconciliation() {
|
||||||
|
return useState(useService("bankReconciliation"));
|
||||||
|
}
|
||||||
7
fusion_accounting_bank_rec/models/__init__.py
Normal file
7
fusion_accounting_bank_rec/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from . import fusion_reconcile_pattern
|
||||||
|
from . import fusion_reconcile_precedent
|
||||||
|
from . import fusion_reconcile_suggestion
|
||||||
|
from . import fusion_bank_rec_widget
|
||||||
|
from . import account_bank_statement_line
|
||||||
|
from . import account_reconcile_model
|
||||||
|
from . import fusion_reconcile_engine
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Inherit account.bank.statement.line to add Phase 1 widget compute fields.
|
||||||
|
|
||||||
|
These fields are NOT stored — they're computed on-the-fly so the OWL widget
|
||||||
|
can render confidence badges without round-tripping. Performance OK because
|
||||||
|
the widget loads ~50-200 lines per kanban open and each compute is a single
|
||||||
|
indexed query into fusion.reconcile.suggestion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBankStatementLine(models.Model):
|
||||||
|
_inherit = "account.bank.statement.line"
|
||||||
|
|
||||||
|
# Top suggestion + its band — for the inline AI confidence badge
|
||||||
|
fusion_top_suggestion_id = fields.Many2one(
|
||||||
|
'fusion.reconcile.suggestion',
|
||||||
|
compute='_compute_top_suggestion',
|
||||||
|
store=False,
|
||||||
|
help="Highest-ranked pending AI suggestion for this line")
|
||||||
|
fusion_confidence_band = fields.Selection(
|
||||||
|
[('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')],
|
||||||
|
compute='_compute_top_suggestion',
|
||||||
|
store=False,
|
||||||
|
default='none',
|
||||||
|
help="Quick-render colour band for the OWL widget badge")
|
||||||
|
|
||||||
|
# Mirror of Enterprise's bank_statement_attachment_ids surface field.
|
||||||
|
# Defined here so fusion's widget can render attachments without
|
||||||
|
# depending on account_accountant being installed.
|
||||||
|
bank_statement_attachment_ids = fields.One2many(
|
||||||
|
'ir.attachment',
|
||||||
|
compute='_compute_bank_statement_attachment_ids',
|
||||||
|
help="Attachments on the underlying account.move; mirrored for the OWL widget")
|
||||||
|
|
||||||
|
def _compute_top_suggestion(self):
|
||||||
|
Suggestion = self.env['fusion.reconcile.suggestion'].sudo()
|
||||||
|
for line in self:
|
||||||
|
top = Suggestion.search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
('rank', '=', 1),
|
||||||
|
], limit=1)
|
||||||
|
line.fusion_top_suggestion_id = top
|
||||||
|
line.fusion_confidence_band = top.confidence_band if top else 'none'
|
||||||
|
|
||||||
|
@api.depends('move_id', 'move_id.attachment_ids')
|
||||||
|
def _compute_bank_statement_attachment_ids(self):
|
||||||
|
for line in self:
|
||||||
|
line.bank_statement_attachment_ids = (
|
||||||
|
line.move_id.attachment_ids if line.move_id else self.env['ir.attachment']
|
||||||
|
)
|
||||||
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Inherit account.reconcile.model to add Phase 1 AI integration hooks.
|
||||||
|
|
||||||
|
This is a minimal extension placeholder for now — Phase 1+ phases may
|
||||||
|
expand it (e.g., to attach AI confidence rules to reconcile-model
|
||||||
|
auto-fires). The shared-field-ownership for `created_automatically`
|
||||||
|
already lives in fusion_accounting_core; this file is for fusion_bank_rec
|
||||||
|
specific extensions only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountReconcileModel(models.Model):
|
||||||
|
_inherit = "account.reconcile.model"
|
||||||
|
|
||||||
|
fusion_ai_confidence_threshold = fields.Float(
|
||||||
|
string="AI confidence threshold",
|
||||||
|
default=0.0,
|
||||||
|
help="If >0.0, fusion AI suggestions matching this rule are auto-applied "
|
||||||
|
"only when their confidence ≥ this threshold. 0.0 = no AI filtering.")
|
||||||
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Per-request widget state. Holds the kanban-load response shape so the
|
||||||
|
controller can return one well-typed object.
|
||||||
|
|
||||||
|
This is a TransientModel (no DB persistence beyond the request). The OWL
|
||||||
|
widget reads pre-computed fusion.reconcile.suggestion rows directly via
|
||||||
|
the controller; this model is just a typed envelope for the kanban-open
|
||||||
|
action."""
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionBankRecWidget(models.TransientModel):
|
||||||
|
_name = "fusion.bank.rec.widget"
|
||||||
|
_description = "Bank reconciliation widget state (transient)"
|
||||||
|
|
||||||
|
journal_id = fields.Many2one('account.journal',
|
||||||
|
domain="[('type', '=', 'bank')]")
|
||||||
|
statement_line_ids = fields.Many2many('account.bank.statement.line')
|
||||||
|
summary_count = fields.Integer(
|
||||||
|
help="Number of unreconciled lines visible in this widget")
|
||||||
|
summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
|
||||||
|
currency_id = fields.Many2one('res.currency',
|
||||||
|
related='journal_id.currency_id',
|
||||||
|
store=False, readonly=True)
|
||||||
|
|
||||||
|
def action_open_kanban(self):
|
||||||
|
"""Return a window action opening the OWL kanban for this journal."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'fusion_bank_rec_kanban',
|
||||||
|
'params': {'journal_id': self.journal_id.id},
|
||||||
|
}
|
||||||
476
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
476
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
"""The reconcile engine — orchestrator for all bank-line reconciliations.
|
||||||
|
|
||||||
|
Public API: 6 methods. All other code (controllers, AI tools, wizards)
|
||||||
|
must go through these methods; no direct ORM writes to
|
||||||
|
``account.partial.reconcile`` from anywhere else.
|
||||||
|
|
||||||
|
V19 mechanics (per Enterprise's bank_rec_widget pattern):
|
||||||
|
|
||||||
|
A bank statement line creates an ``account.move`` with two journal
|
||||||
|
items: a *liquidity* line on the journal's default account, and a
|
||||||
|
*suspense* line on the journal's suspense account. Reconciliation
|
||||||
|
replaces the suspense line with one or more *counterpart* lines posted
|
||||||
|
to the matched invoices' receivable / payable accounts (or the write-off
|
||||||
|
account), then calls Odoo's standard ``account.move.line.reconcile()``
|
||||||
|
on each counterpart + invoice pair.
|
||||||
|
|
||||||
|
Internal pipeline (per spec Section 3.3):
|
||||||
|
|
||||||
|
1. Validate (period not locked, mandatory args present).
|
||||||
|
2. Compute counterpart vals from ``against_lines`` and optional write-off.
|
||||||
|
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
|
||||||
|
any prior other lines, append the new counterparts.
|
||||||
|
4. Reconcile each counterpart with its matched invoice line.
|
||||||
|
5. Audit (``mail.message``) + record precedent for future learning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.fields import Command
|
||||||
|
|
||||||
|
from ..services.matching_strategies import (
|
||||||
|
AmountExactStrategy,
|
||||||
|
Candidate,
|
||||||
|
FIFOStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
)
|
||||||
|
from ..services.confidence_scoring import score_candidates
|
||||||
|
from ..services.memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcileEngine(models.AbstractModel):
|
||||||
|
_name = "fusion.reconcile.engine"
|
||||||
|
_description = "Fusion Bank Reconciliation Engine"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PUBLIC API (6 methods)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reconcile_one(self, statement_line, *, against_lines=None,
|
||||||
|
write_off_vals=None):
|
||||||
|
"""Reconcile one bank line against a set of journal items.
|
||||||
|
|
||||||
|
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||||
|
'write_off_move_id': int|None}``
|
||||||
|
"""
|
||||||
|
if not statement_line:
|
||||||
|
raise ValidationError(_("statement_line is required"))
|
||||||
|
statement_line.ensure_one()
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
against_lines = against_lines or AML
|
||||||
|
if not against_lines and not write_off_vals:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Either against_lines or write_off_vals required"))
|
||||||
|
|
||||||
|
self._validate_reconcile(statement_line, against_lines)
|
||||||
|
|
||||||
|
bank_move = statement_line.move_id
|
||||||
|
liquidity_lines, suspense_lines, other_lines = (
|
||||||
|
statement_line._seek_for_lines())
|
||||||
|
|
||||||
|
# The bank move must stay balanced after we rewrite line_ids.
|
||||||
|
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||||
|
# the new counterparts must sum to the inverse. We allocate the
|
||||||
|
# available bank amount across against_lines, clamped to each
|
||||||
|
# invoice's residual; any leftover goes to the write-off line (or
|
||||||
|
# raises if no write-off was requested).
|
||||||
|
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||||
|
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||||
|
remaining = abs(liq_balance)
|
||||||
|
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||||
|
cp_sign = -1 if liq_balance >= 0 else 1
|
||||||
|
|
||||||
|
new_counterpart_vals = []
|
||||||
|
for inv_line in against_lines:
|
||||||
|
inv_residual = inv_line.amount_residual
|
||||||
|
# Clamp so we never write more than the invoice residual nor more
|
||||||
|
# than what the bank line can pay.
|
||||||
|
allocate = min(remaining, abs(inv_residual))
|
||||||
|
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||||
|
statement_line, inv_line,
|
||||||
|
allocated_balance=cp_sign * allocate,
|
||||||
|
))
|
||||||
|
remaining -= allocate
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
write_off_move_id = None
|
||||||
|
if write_off_vals:
|
||||||
|
# Write-off absorbs whatever the against_lines didn't cover.
|
||||||
|
wo_balance = cp_sign * remaining
|
||||||
|
# If user passed an explicit amount and there are no against_lines,
|
||||||
|
# honour the explicit amount (covers the pure write-off case).
|
||||||
|
if (write_off_vals.get('amount') is not None
|
||||||
|
and not against_lines):
|
||||||
|
wo_balance = -write_off_vals['amount']
|
||||||
|
new_counterpart_vals.append(self._build_write_off_vals(
|
||||||
|
statement_line, write_off_vals, balance=wo_balance,
|
||||||
|
))
|
||||||
|
remaining = 0
|
||||||
|
|
||||||
|
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||||
|
# else, append new counterparts.
|
||||||
|
ops = []
|
||||||
|
for line in (suspense_lines | other_lines):
|
||||||
|
ops.append(Command.unlink(line.id))
|
||||||
|
for vals in new_counterpart_vals:
|
||||||
|
ops.append(Command.create(vals))
|
||||||
|
|
||||||
|
editable_move = bank_move.with_context(
|
||||||
|
force_delete=True, skip_readonly_check=True)
|
||||||
|
prior_line_ids = set(bank_move.line_ids.ids)
|
||||||
|
editable_move.write({'line_ids': ops})
|
||||||
|
|
||||||
|
new_lines = bank_move.line_ids.filtered(
|
||||||
|
lambda line: line.id not in prior_line_ids)
|
||||||
|
|
||||||
|
# Reconcile each new counterpart with its matched invoice line.
|
||||||
|
# The first N new lines correspond to the first N against_lines
|
||||||
|
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||||
|
# Any trailing new line is a write-off and has no invoice pair.
|
||||||
|
Partial = self.env['account.partial.reconcile']
|
||||||
|
new_partial_ids = []
|
||||||
|
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||||
|
len(against_lines))]
|
||||||
|
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||||
|
pair = new_line | inv_line
|
||||||
|
existing = set(Partial.search([
|
||||||
|
'|',
|
||||||
|
('debit_move_id', 'in', pair.ids),
|
||||||
|
('credit_move_id', 'in', pair.ids),
|
||||||
|
]).ids)
|
||||||
|
pair.reconcile()
|
||||||
|
added = Partial.search([
|
||||||
|
'|',
|
||||||
|
('debit_move_id', 'in', pair.ids),
|
||||||
|
('credit_move_id', 'in', pair.ids),
|
||||||
|
]).filtered(lambda p: p.id not in existing)
|
||||||
|
new_partial_ids.extend(added.ids)
|
||||||
|
|
||||||
|
self._post_audit(
|
||||||
|
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||||
|
if against_lines:
|
||||||
|
self._record_precedent(statement_line, against_lines)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'partial_ids': new_partial_ids,
|
||||||
|
'exchange_diff_move_id': None,
|
||||||
|
'write_off_move_id': write_off_move_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||||
|
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||||
|
|
||||||
|
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||||
|
'errors': [...]}``
|
||||||
|
"""
|
||||||
|
reconciled = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = []
|
||||||
|
for line in statement_lines:
|
||||||
|
if line.is_reconciled:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidates = self._fetch_candidates(line)
|
||||||
|
picked = self._apply_strategy(line, candidates, strategy)
|
||||||
|
if picked:
|
||||||
|
self.reconcile_one(line, against_lines=picked)
|
||||||
|
reconciled += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
errors.append({'line_id': line.id, 'error': str(e)})
|
||||||
|
_logger.warning(
|
||||||
|
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||||
|
return {
|
||||||
|
'reconciled_count': reconciled,
|
||||||
|
'skipped': skipped,
|
||||||
|
'errors': errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||||
|
"""Compute and persist AI suggestions per line.
|
||||||
|
|
||||||
|
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||||
|
for line in statement_lines:
|
||||||
|
candidates_records = self._fetch_candidates(line)
|
||||||
|
if not candidates_records:
|
||||||
|
continue
|
||||||
|
candidates_dataclasses = self._records_to_candidates(
|
||||||
|
line, candidates_records)
|
||||||
|
scored = score_candidates(
|
||||||
|
self.env,
|
||||||
|
statement_line=line,
|
||||||
|
candidates=candidates_dataclasses,
|
||||||
|
k=limit_per_line,
|
||||||
|
use_ai=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
Suggestion.search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
]).write({'state': 'superseded'})
|
||||||
|
|
||||||
|
line_suggestions = []
|
||||||
|
for rank, s in enumerate(scored, start=1):
|
||||||
|
sug = Suggestion.create({
|
||||||
|
'company_id': line.company_id.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': rank,
|
||||||
|
'reasoning': s.reasoning,
|
||||||
|
'score_amount_match': s.score_amount_match,
|
||||||
|
'score_partner_pattern': s.score_partner_pattern,
|
||||||
|
'score_precedent_similarity': s.score_precedent_similarity,
|
||||||
|
'score_ai_rerank': s.score_ai_rerank,
|
||||||
|
'generated_by': 'on_demand',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
line_suggestions.append({
|
||||||
|
'id': sug.id,
|
||||||
|
'rank': rank,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'reasoning': s.reasoning,
|
||||||
|
'candidate_id': s.candidate_id,
|
||||||
|
})
|
||||||
|
out[line.id] = line_suggestions
|
||||||
|
return out
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def accept_suggestion(self, suggestion):
|
||||||
|
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||||
|
|
||||||
|
Returns: same shape as ``reconcile_one``.
|
||||||
|
"""
|
||||||
|
if isinstance(suggestion, int):
|
||||||
|
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||||
|
suggestion)
|
||||||
|
suggestion.ensure_one()
|
||||||
|
line = suggestion.statement_line_id
|
||||||
|
against = suggestion.proposed_move_line_ids
|
||||||
|
result = self.reconcile_one(line, against_lines=against)
|
||||||
|
suggestion.write({
|
||||||
|
'state': 'accepted',
|
||||||
|
'accepted_at': fields.Datetime.now(),
|
||||||
|
'accepted_by': self.env.uid,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||||
|
"""Create a write-off move + reconcile the bank line against it.
|
||||||
|
|
||||||
|
Returns: same shape as ``reconcile_one``.
|
||||||
|
"""
|
||||||
|
write_off_vals = {
|
||||||
|
'account_id': account.id if hasattr(account, 'id') else account,
|
||||||
|
'amount': amount,
|
||||||
|
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||||
|
else tax_id),
|
||||||
|
'label': label,
|
||||||
|
}
|
||||||
|
return self.reconcile_one(
|
||||||
|
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def unreconcile(self, partial_reconciles):
|
||||||
|
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||||
|
|
||||||
|
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||||
|
one or more counterpart lines, simply deleting the
|
||||||
|
``account.partial.reconcile`` rows is not enough — the bank move
|
||||||
|
would still look reconciled (no suspense line, no residual). We
|
||||||
|
delegate to V19's standard ``account.bank.statement.line.
|
||||||
|
action_undo_reconciliation`` for any affected bank line, which
|
||||||
|
clears the partials AND restores the original suspense state.
|
||||||
|
|
||||||
|
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||||
|
"""
|
||||||
|
partial_reconciles = partial_reconciles.exists()
|
||||||
|
if not partial_reconciles:
|
||||||
|
return {'unreconciled_line_ids': []}
|
||||||
|
all_lines = (
|
||||||
|
partial_reconciles.mapped('debit_move_id')
|
||||||
|
| partial_reconciles.mapped('credit_move_id')
|
||||||
|
)
|
||||||
|
line_ids = all_lines.ids
|
||||||
|
# Find any bank statement lines whose move owns one of these journal
|
||||||
|
# items; route them through the standard undo flow which both
|
||||||
|
# deletes the partials and restores the suspense line.
|
||||||
|
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||||
|
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||||
|
])
|
||||||
|
if affected_bank_lines:
|
||||||
|
affected_bank_lines.action_undo_reconciliation()
|
||||||
|
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||||
|
# gets a direct unlink as a fallback.
|
||||||
|
remaining = partial_reconciles.exists()
|
||||||
|
if remaining:
|
||||||
|
remaining.unlink()
|
||||||
|
return {'unreconciled_line_ids': line_ids}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PRIVATE HELPERS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _validate_reconcile(self, statement_line, against_lines):
|
||||||
|
"""Phase 2: structural + safety checks."""
|
||||||
|
if not statement_line.exists():
|
||||||
|
raise ValidationError(_("Statement line does not exist"))
|
||||||
|
company = statement_line.company_id
|
||||||
|
line_date = statement_line.date
|
||||||
|
lock_date = company.fiscalyear_lock_date
|
||||||
|
if lock_date and line_date and line_date <= lock_date:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Cannot reconcile: line date %(line)s is on or before fiscal "
|
||||||
|
"year lock date %(lock)s",
|
||||||
|
line=line_date,
|
||||||
|
lock=lock_date,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||||
|
allocated_balance):
|
||||||
|
"""Build the vals for one counterpart line that mirrors an invoice
|
||||||
|
line on the bank move.
|
||||||
|
|
||||||
|
``allocated_balance`` is the signed company-currency balance to write
|
||||||
|
on the counterpart. It is clamped (by the caller) so that the bank
|
||||||
|
move stays balanced and no invoice gets over-paid. We scale
|
||||||
|
``amount_currency`` proportionally for multi-currency lines.
|
||||||
|
"""
|
||||||
|
inv_residual = inv_line.amount_residual
|
||||||
|
if inv_residual:
|
||||||
|
scale = abs(allocated_balance) / abs(inv_residual)
|
||||||
|
else:
|
||||||
|
scale = 1.0
|
||||||
|
amount_currency = -inv_line.amount_residual_currency * scale
|
||||||
|
return {
|
||||||
|
'name': inv_line.name or statement_line.payment_ref or '',
|
||||||
|
'account_id': inv_line.account_id.id,
|
||||||
|
'partner_id': (inv_line.partner_id.id
|
||||||
|
if inv_line.partner_id else False),
|
||||||
|
'currency_id': inv_line.currency_id.id,
|
||||||
|
'amount_currency': amount_currency,
|
||||||
|
'balance': allocated_balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||||
|
balance):
|
||||||
|
"""Build the vals for a write-off counterpart line on the bank move.
|
||||||
|
|
||||||
|
``balance`` is the signed company-currency balance the write-off
|
||||||
|
line must carry to keep the bank move balanced.
|
||||||
|
"""
|
||||||
|
vals = {
|
||||||
|
'name': write_off_vals.get('label') or _('Write-off'),
|
||||||
|
'account_id': write_off_vals['account_id'],
|
||||||
|
'partner_id': (statement_line.partner_id.id
|
||||||
|
if statement_line.partner_id else False),
|
||||||
|
'balance': balance,
|
||||||
|
}
|
||||||
|
if write_off_vals.get('tax_id'):
|
||||||
|
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def _fetch_candidates(self, statement_line):
|
||||||
|
"""SQL pre-filter: open journal items matching partner + reconcilable
|
||||||
|
account."""
|
||||||
|
domain = [
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.reconcile', '=', True),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('display_type', 'not in', ('line_section', 'line_note')),
|
||||||
|
]
|
||||||
|
if statement_line.partner_id:
|
||||||
|
domain.append(('partner_id', '=', statement_line.partner_id.id))
|
||||||
|
return self.env['account.move.line'].search(domain, limit=200)
|
||||||
|
|
||||||
|
def _records_to_candidates(self, statement_line, records):
|
||||||
|
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
|
||||||
|
today = fields.Date.today()
|
||||||
|
result = []
|
||||||
|
for c in records:
|
||||||
|
ref_date = c.date_maturity or c.date or today
|
||||||
|
age_days = (today - ref_date).days
|
||||||
|
result.append(Candidate(
|
||||||
|
id=c.id,
|
||||||
|
amount=abs(c.amount_residual) or abs(c.balance),
|
||||||
|
partner_id=c.partner_id.id if c.partner_id else 0,
|
||||||
|
age_days=age_days,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _apply_strategy(self, line, candidate_records, strategy):
|
||||||
|
"""Apply the named strategy. Returns matching ``account.move.line``
|
||||||
|
recordset, or empty recordset if nothing matched."""
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
if not candidate_records:
|
||||||
|
return AML
|
||||||
|
candidate_dcs = self._records_to_candidates(line, candidate_records)
|
||||||
|
bank_amount = abs(line.amount)
|
||||||
|
if strategy == 'auto':
|
||||||
|
for strat_class in (AmountExactStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
FIFOStrategy):
|
||||||
|
result = strat_class().match(
|
||||||
|
bank_amount=bank_amount, candidates=candidate_dcs)
|
||||||
|
if result.picked_ids:
|
||||||
|
return AML.browse(result.picked_ids)
|
||||||
|
return AML
|
||||||
|
|
||||||
|
def _post_audit(self, statement_line, partial_ids, source):
|
||||||
|
"""Append an audit log to the bank-line move's chatter."""
|
||||||
|
if not statement_line.move_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
statement_line.move_id.message_post(
|
||||||
|
body=_(
|
||||||
|
"Reconciled via %(source)s; %(count)d partial(s) created: "
|
||||||
|
"%(ids)s",
|
||||||
|
source=source,
|
||||||
|
count=len(partial_ids),
|
||||||
|
ids=partial_ids,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.debug(
|
||||||
|
"Audit log skipped for line %s: %s", statement_line.id, e)
|
||||||
|
|
||||||
|
def _record_precedent(self, statement_line, against_lines):
|
||||||
|
"""Append a precedent for future pattern learning. Best-effort."""
|
||||||
|
if not against_lines:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.env['fusion.reconcile.precedent'].sudo().create({
|
||||||
|
'company_id': statement_line.company_id.id,
|
||||||
|
'partner_id': (statement_line.partner_id.id
|
||||||
|
if statement_line.partner_id else False),
|
||||||
|
'amount': abs(statement_line.amount),
|
||||||
|
'currency_id': statement_line.currency_id.id,
|
||||||
|
'date': statement_line.date,
|
||||||
|
'memo_tokens': ','.join(
|
||||||
|
tokenize_memo(statement_line.payment_ref)),
|
||||||
|
'journal_id': statement_line.journal_id.id,
|
||||||
|
'matched_move_line_count': len(against_lines),
|
||||||
|
'matched_account_ids': ','.join(
|
||||||
|
str(i) for i in against_lines.mapped('account_id').ids),
|
||||||
|
'reconciler_user_id': self.env.uid,
|
||||||
|
'reconciled_at': fields.Datetime.now(),
|
||||||
|
'source': 'manual',
|
||||||
|
})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning(
|
||||||
|
"Failed to record precedent for line %s: %s",
|
||||||
|
statement_line.id, e)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Per-partner bank reconciliation pattern aggregate.
|
||||||
|
|
||||||
|
One row per (company_id, partner_id). Continuously summarises HOW this
|
||||||
|
partner gets reconciled. Recomputed nightly via cron from the precedent
|
||||||
|
table. Used as a feature input to confidence_scoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcilePattern(models.Model):
|
||||||
|
_name = "fusion.reconcile.pattern"
|
||||||
|
_description = "Per-partner bank reconciliation pattern aggregate"
|
||||||
|
_rec_name = "partner_id"
|
||||||
|
|
||||||
|
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
partner_id = fields.Many2one('res.partner', required=True, index=True)
|
||||||
|
|
||||||
|
# Volume + cadence
|
||||||
|
reconcile_count = fields.Integer(default=0,
|
||||||
|
help="Total past reconciles for this partner")
|
||||||
|
typical_amount_range = fields.Char(
|
||||||
|
help="e.g. '$1,200 – $2,400 (median $1,847.50)'")
|
||||||
|
typical_cadence_days = fields.Float(
|
||||||
|
help="Mean inter-reconcile days")
|
||||||
|
typical_day_of_month = fields.Char(
|
||||||
|
help="e.g. '1st, 15th'")
|
||||||
|
|
||||||
|
# Matching strategy used historically
|
||||||
|
pref_strategy = fields.Selection([
|
||||||
|
('exact_amount', 'Exact-amount-first'),
|
||||||
|
('fifo', 'FIFO oldest-due-first'),
|
||||||
|
('multi_invoice', 'Multi-invoice consolidation'),
|
||||||
|
('cherry_pick', 'Cherry-pick specific invoices'),
|
||||||
|
])
|
||||||
|
pref_account_id = fields.Many2one('account.account',
|
||||||
|
help="Most-used target account")
|
||||||
|
|
||||||
|
# Memo signature
|
||||||
|
common_memo_tokens = fields.Char(
|
||||||
|
help="Comma-separated tokens that appear in ≥30% of past reconciles")
|
||||||
|
|
||||||
|
# Tax + write-off habits
|
||||||
|
common_writeoff_account_id = fields.Many2one('account.account')
|
||||||
|
common_writeoff_tax_id = fields.Many2one('account.tax')
|
||||||
|
typical_writeoff_amount = fields.Float(
|
||||||
|
help="e.g. 0.05 for rounding diffs")
|
||||||
|
|
||||||
|
last_refreshed_at = fields.Datetime()
|
||||||
|
|
||||||
|
_uniq_company_partner = models.Constraint(
|
||||||
|
'unique(company_id, partner_id)',
|
||||||
|
'One pattern row per (company, partner) — already exists.',
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Per-historical-decision reconciliation memory.
|
||||||
|
|
||||||
|
One row per past reconciliation. Holds the full feature vector + outcome,
|
||||||
|
used by precedent_lookup for K-nearest-neighbour search when scoring a
|
||||||
|
new bank line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcilePrecedent(models.Model):
|
||||||
|
_name = "fusion.reconcile.precedent"
|
||||||
|
_description = "Historical bank reconciliation decision (memory)"
|
||||||
|
_order = "reconciled_at desc, id desc"
|
||||||
|
|
||||||
|
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
partner_id = fields.Many2one('res.partner', index=True)
|
||||||
|
|
||||||
|
# Bank line features (the "input")
|
||||||
|
amount = fields.Monetary(currency_field='currency_id')
|
||||||
|
currency_id = fields.Many2one('res.currency')
|
||||||
|
date = fields.Date()
|
||||||
|
memo_tokens = fields.Char(
|
||||||
|
help="Comma-separated normalized memo tokens (output of memo_tokenizer)")
|
||||||
|
journal_id = fields.Many2one('account.journal')
|
||||||
|
|
||||||
|
# Outcome (the "decision made")
|
||||||
|
matched_move_line_count = fields.Integer(
|
||||||
|
help="1 = exact, 2-3 = consolidation, etc.")
|
||||||
|
matched_account_ids = fields.Char(
|
||||||
|
help="Comma-separated account.account IDs that were matched against")
|
||||||
|
matched_invoice_ages_days = fields.Char(
|
||||||
|
help="Comma-separated days-old at reconcile time, e.g. '12, 45, 78'")
|
||||||
|
write_off_amount = fields.Float()
|
||||||
|
write_off_account_id = fields.Many2one('account.account')
|
||||||
|
exchange_diff = fields.Boolean()
|
||||||
|
|
||||||
|
# Provenance
|
||||||
|
reconciler_user_id = fields.Many2one('res.users')
|
||||||
|
reconciled_at = fields.Datetime()
|
||||||
|
source = fields.Selection([
|
||||||
|
('historical_bootstrap', 'Imported from history'),
|
||||||
|
('manual', 'Manual reconcile via fusion'),
|
||||||
|
('ai_accepted', 'AI suggestion accepted'),
|
||||||
|
('auto_rule', 'account.reconcile.model auto-fired'),
|
||||||
|
], required=True)
|
||||||
|
|
||||||
|
# No uniqueness constraint — multiple reconciles can share features
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Persisted AI suggestions for bank line reconciliations.
|
||||||
|
|
||||||
|
One row per (statement_line, candidate_match). The OWL widget reads these
|
||||||
|
to render confidence badges; users accept/reject which feeds back into
|
||||||
|
the pattern learning system.
|
||||||
|
|
||||||
|
The AI never writes account.partial.reconcile directly — it writes
|
||||||
|
suggestions here, and the user (or batch-accept action) approves them
|
||||||
|
through the engine's accept_suggestion() method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcileSuggestion(models.Model):
|
||||||
|
_name = "fusion.reconcile.suggestion"
|
||||||
|
_description = "AI-generated bank reconciliation suggestion"
|
||||||
|
_order = "statement_line_id, confidence desc"
|
||||||
|
|
||||||
|
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
statement_line_id = fields.Many2one('account.bank.statement.line',
|
||||||
|
required=True, index=True, ondelete='cascade')
|
||||||
|
|
||||||
|
# The proposal
|
||||||
|
proposed_move_line_ids = fields.Many2many('account.move.line',
|
||||||
|
string="Proposed matches")
|
||||||
|
proposed_write_off_amount = fields.Monetary(currency_field='currency_id')
|
||||||
|
proposed_write_off_account_id = fields.Many2one('account.account')
|
||||||
|
currency_id = fields.Many2one('res.currency',
|
||||||
|
related='statement_line_id.currency_id',
|
||||||
|
store=True)
|
||||||
|
|
||||||
|
# Scoring
|
||||||
|
confidence = fields.Float(required=True)
|
||||||
|
confidence_band = fields.Selection([
|
||||||
|
('high', 'High (>=95%)'),
|
||||||
|
('medium', 'Medium (70-94%)'),
|
||||||
|
('low', 'Low (50-69%)'),
|
||||||
|
('none', 'No confidence (<50%)'),
|
||||||
|
], compute='_compute_band', store=True)
|
||||||
|
rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives")
|
||||||
|
reasoning = fields.Text(help="Human-readable explanation")
|
||||||
|
|
||||||
|
# Feature breakdown (for transparency + future learning)
|
||||||
|
score_amount_match = fields.Float()
|
||||||
|
score_partner_pattern = fields.Float()
|
||||||
|
score_precedent_similarity = fields.Float()
|
||||||
|
score_ai_rerank = fields.Float()
|
||||||
|
|
||||||
|
# Provenance
|
||||||
|
generated_at = fields.Datetime(default=fields.Datetime.now)
|
||||||
|
generated_by = fields.Selection([
|
||||||
|
('cron_batch', 'Batch cron'),
|
||||||
|
('on_demand', 'User refreshed alternatives'),
|
||||||
|
('on_open', 'Widget opened (lazy)'),
|
||||||
|
])
|
||||||
|
provider_used = fields.Char(
|
||||||
|
help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'")
|
||||||
|
tokens_used = fields.Integer(help="if AI re-rank invoked")
|
||||||
|
generation_ms = fields.Integer(help="latency for monitoring")
|
||||||
|
|
||||||
|
# Lifecycle
|
||||||
|
state = fields.Selection([
|
||||||
|
('pending', 'Pending review'),
|
||||||
|
('accepted', 'Accepted'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('superseded', 'Superseded by newer suggestion'),
|
||||||
|
('stale', 'Stale (line changed since)'),
|
||||||
|
], default='pending', required=True, index=True)
|
||||||
|
accepted_at = fields.Datetime()
|
||||||
|
accepted_by = fields.Many2one('res.users')
|
||||||
|
rejected_at = fields.Datetime()
|
||||||
|
rejected_reason = fields.Selection([
|
||||||
|
('wrong_invoice', 'Wrong invoice'),
|
||||||
|
('wrong_partner', 'Wrong partner'),
|
||||||
|
('wrong_amount', 'Amount off'),
|
||||||
|
('not_a_match', 'No good match exists'),
|
||||||
|
('other', 'Other'),
|
||||||
|
])
|
||||||
|
|
||||||
|
_confidence_in_range = models.Constraint(
|
||||||
|
'CHECK (confidence >= 0.0 AND confidence <= 1.0)',
|
||||||
|
'Confidence must be between 0.0 and 1.0',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('confidence')
|
||||||
|
def _compute_band(self):
|
||||||
|
for sug in self:
|
||||||
|
c = sug.confidence
|
||||||
|
if c >= 0.95:
|
||||||
|
sug.confidence_band = 'high'
|
||||||
|
elif c >= 0.70:
|
||||||
|
sug.confidence_band = 'medium'
|
||||||
|
elif c >= 0.50:
|
||||||
|
sug.confidence_band = 'low'
|
||||||
|
else:
|
||||||
|
sug.confidence_band = 'none'
|
||||||
8
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
8
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||||
|
access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||||
|
access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||||
|
access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||||
|
access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||||
|
access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||||
|
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
|
||||||
|
6
fusion_accounting_bank_rec/services/__init__.py
Normal file
6
fusion_accounting_bank_rec/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from . import memo_tokenizer
|
||||||
|
from . import exchange_diff
|
||||||
|
from . import matching_strategies
|
||||||
|
from . import precedent_lookup
|
||||||
|
from . import pattern_extractor
|
||||||
|
from . import confidence_scoring
|
||||||
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""4-pass confidence scoring pipeline.
|
||||||
|
|
||||||
|
Pass 1: SQL filter — partner match + reconcilable account (done by caller — engine._fetch_candidates)
|
||||||
|
Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
|
||||||
|
Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
|
||||||
|
Pass 4: Persist as fusion.reconcile.suggestion rows (done by caller — engine.suggest_matches)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .matching_strategies import Candidate
|
||||||
|
from .precedent_lookup import find_nearest_precedents
|
||||||
|
from .memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScoredCandidate:
|
||||||
|
candidate_id: int
|
||||||
|
confidence: float
|
||||||
|
reasoning: str
|
||||||
|
score_amount_match: float
|
||||||
|
score_partner_pattern: float
|
||||||
|
score_precedent_similarity: float
|
||||||
|
score_ai_rerank: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True):
|
||||||
|
"""Score and rank candidate matches for a statement line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Odoo env
|
||||||
|
statement_line: account.bank.statement.line recordset (singleton)
|
||||||
|
candidates: list of Candidate dataclasses (from matching_strategies)
|
||||||
|
k: max number of scored candidates to return
|
||||||
|
use_ai: if True AND a provider is configured, invoke AI re-rank
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of ScoredCandidate sorted by confidence desc, max length k.
|
||||||
|
"""
|
||||||
|
if not candidates or not statement_line:
|
||||||
|
return []
|
||||||
|
|
||||||
|
partner_id = statement_line.partner_id.id if statement_line.partner_id else None
|
||||||
|
bank_amount = abs(statement_line.amount)
|
||||||
|
memo_tokens = tokenize_memo(statement_line.payment_ref)
|
||||||
|
|
||||||
|
pattern = None
|
||||||
|
if partner_id:
|
||||||
|
pattern = env['fusion.reconcile.pattern'].sudo().search(
|
||||||
|
[('partner_id', '=', partner_id)], limit=1)
|
||||||
|
if not pattern:
|
||||||
|
pattern = None
|
||||||
|
|
||||||
|
precedents = []
|
||||||
|
if partner_id:
|
||||||
|
precedents = find_nearest_precedents(
|
||||||
|
env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for cand in candidates:
|
||||||
|
amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
|
||||||
|
pattern_score = _pattern_score(cand, pattern, bank_amount)
|
||||||
|
precedent_score = _precedent_score(cand, precedents)
|
||||||
|
confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
|
||||||
|
|
||||||
|
reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
|
||||||
|
scored.append(ScoredCandidate(
|
||||||
|
candidate_id=cand.id,
|
||||||
|
confidence=round(confidence, 3),
|
||||||
|
reasoning=reasoning,
|
||||||
|
score_amount_match=round(amount_score, 3),
|
||||||
|
score_partner_pattern=round(pattern_score, 3),
|
||||||
|
score_precedent_similarity=round(precedent_score, 3),
|
||||||
|
))
|
||||||
|
|
||||||
|
scored.sort(key=lambda s: -s.confidence)
|
||||||
|
top_k = scored[:k]
|
||||||
|
|
||||||
|
if use_ai:
|
||||||
|
provider = _get_provider(env, 'bank_rec_suggest')
|
||||||
|
if provider is not None:
|
||||||
|
try:
|
||||||
|
top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("AI re-rank failed, using statistical scoring: %s", e)
|
||||||
|
|
||||||
|
return top_k
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_score(cand, pattern, bank_amount) -> float:
|
||||||
|
"""How well does this candidate fit the partner's typical pattern?"""
|
||||||
|
if not pattern:
|
||||||
|
return 0.5
|
||||||
|
score = 0.5
|
||||||
|
if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
|
||||||
|
score = 1.0
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _precedent_score(cand, precedents) -> float:
|
||||||
|
"""How similar is this candidate to past precedents?"""
|
||||||
|
if not precedents:
|
||||||
|
return 0.5
|
||||||
|
best = max((p.similarity_score for p in precedents), default=0.5)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
|
||||||
|
parts = []
|
||||||
|
if amount_score >= 0.99:
|
||||||
|
parts.append("Exact amount match")
|
||||||
|
elif amount_score >= 0.95:
|
||||||
|
parts.append("Amount close")
|
||||||
|
if pattern and pattern.reconcile_count > 5:
|
||||||
|
parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
|
||||||
|
if precedent_score >= 0.8:
|
||||||
|
parts.append("Strong precedent match")
|
||||||
|
return " · ".join(parts) if parts else "Weak signal"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider(env, feature_name):
|
||||||
|
"""Look up provider name from per-feature config; instantiate adapter.
|
||||||
|
|
||||||
|
Returns None if no provider configured (statistical-only mode)."""
|
||||||
|
param = env['ir.config_parameter'].sudo()
|
||||||
|
provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
|
||||||
|
if not provider_name:
|
||||||
|
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||||
|
if not provider_name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||||
|
except ImportError:
|
||||||
|
_logger.warning("fusion_accounting_ai adapters not importable")
|
||||||
|
return None
|
||||||
|
if provider_name.startswith('openai'):
|
||||||
|
return OpenAIAdapter(env)
|
||||||
|
elif provider_name.startswith('claude'):
|
||||||
|
return ClaudeAdapter(env)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
|
||||||
|
"""Send top-K candidates + features to LLM for re-rank. Parse JSON response.
|
||||||
|
|
||||||
|
On any failure (network, JSON parse, missing key), return scored unchanged."""
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
|
||||||
|
except ImportError:
|
||||||
|
_logger.debug("bank_rec_prompt not yet available; skipping AI re-rank")
|
||||||
|
return scored
|
||||||
|
|
||||||
|
system, user = build_prompt(statement_line, scored, pattern, precedents)
|
||||||
|
response = provider.complete(
|
||||||
|
system=system,
|
||||||
|
messages=[{'role': 'user', 'content': user}],
|
||||||
|
max_tokens=800,
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(response['content'])
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
return scored
|
||||||
|
|
||||||
|
ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
|
||||||
|
for s in scored:
|
||||||
|
if s.candidate_id in ai_order:
|
||||||
|
s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
|
||||||
|
s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
|
||||||
|
s.confidence = round((s.confidence * 0.4) + (s.score_ai_rerank * 0.6), 3)
|
||||||
|
scored.sort(key=lambda x: -x.confidence)
|
||||||
|
return scored
|
||||||
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Exchange-difference calculation helper.
|
||||||
|
|
||||||
|
Pure-Python FX gain/loss computation. The engine uses this for rapid
|
||||||
|
pre-checks; Odoo's account.move._create_exchange_difference_move() is
|
||||||
|
invoked separately for the actual GL posting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExchangeDiffResult:
|
||||||
|
needs_diff_move: bool
|
||||||
|
diff_amount: float # in company currency; positive = gain, negative = loss
|
||||||
|
line_company_amount: float
|
||||||
|
against_company_amount: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_exchange_diff(*, line_amount, line_currency_code, against_amount,
|
||||||
|
against_currency_code, line_rate, against_rate) -> ExchangeDiffResult:
|
||||||
|
"""Compute whether an exchange-diff move is needed and its magnitude.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line_amount: Bank line amount in its currency
|
||||||
|
line_currency_code: e.g. 'USD'
|
||||||
|
against_amount: Matched journal item amount in its currency
|
||||||
|
against_currency_code: e.g. 'USD' (or different)
|
||||||
|
line_rate: FX rate (foreign per company currency) at line date
|
||||||
|
against_rate: FX rate at journal item posting date
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExchangeDiffResult with needs_diff_move flag and computed diff
|
||||||
|
in company currency (positive = gain, negative = loss).
|
||||||
|
"""
|
||||||
|
line_company = line_amount * line_rate
|
||||||
|
against_company = against_amount * against_rate
|
||||||
|
|
||||||
|
diff = line_company - against_company
|
||||||
|
needs_diff = abs(diff) > 0.005 # rounding tolerance
|
||||||
|
|
||||||
|
return ExchangeDiffResult(
|
||||||
|
needs_diff_move=needs_diff,
|
||||||
|
diff_amount=round(diff, 2),
|
||||||
|
line_company_amount=round(line_company, 2),
|
||||||
|
against_company_amount=round(against_company, 2),
|
||||||
|
)
|
||||||
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Matching strategy classes for the reconcile engine.
|
||||||
|
|
||||||
|
Each strategy takes a bank amount + list of candidate journal items
|
||||||
|
and returns a MatchResult with the picked ids + confidence + residual.
|
||||||
|
Strategies are pure Python; no ORM dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Candidate:
|
||||||
|
id: int
|
||||||
|
amount: float
|
||||||
|
partner_id: int
|
||||||
|
age_days: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchResult:
|
||||||
|
picked_ids: list[int] = field(default_factory=list)
|
||||||
|
confidence: float = 0.0
|
||||||
|
residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated
|
||||||
|
strategy_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance
|
||||||
|
|
||||||
|
|
||||||
|
class AmountExactStrategy:
|
||||||
|
"""Pick a single candidate whose amount equals the bank amount exactly.
|
||||||
|
If multiple candidates match exactly, pick the oldest (FIFO tiebreaker)."""
|
||||||
|
|
||||||
|
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||||
|
exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE]
|
||||||
|
if not exact:
|
||||||
|
return MatchResult(strategy_name='amount_exact')
|
||||||
|
oldest = max(exact, key=lambda c: c.age_days)
|
||||||
|
return MatchResult(
|
||||||
|
picked_ids=[oldest.id],
|
||||||
|
confidence=1.0,
|
||||||
|
residual=0.0,
|
||||||
|
strategy_name='amount_exact',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FIFOStrategy:
|
||||||
|
"""Pick oldest candidates first until the bank amount is exhausted.
|
||||||
|
May produce partial reconcile residual if last candidate doesn't fit exactly."""
|
||||||
|
|
||||||
|
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||||
|
if not candidates:
|
||||||
|
return MatchResult(strategy_name='fifo')
|
||||||
|
oldest_first = sorted(candidates, key=lambda c: -c.age_days)
|
||||||
|
picked = []
|
||||||
|
remaining = bank_amount
|
||||||
|
for c in oldest_first:
|
||||||
|
if remaining <= AMOUNT_TOLERANCE:
|
||||||
|
break
|
||||||
|
picked.append(c.id)
|
||||||
|
remaining -= c.amount
|
||||||
|
|
||||||
|
confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5
|
||||||
|
return MatchResult(
|
||||||
|
picked_ids=picked,
|
||||||
|
confidence=confidence,
|
||||||
|
residual=remaining,
|
||||||
|
strategy_name='fifo',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiInvoiceStrategy:
|
||||||
|
"""Find the smallest combination of candidates summing to the bank amount.
|
||||||
|
Bounded by max_combinations to keep complexity manageable."""
|
||||||
|
|
||||||
|
def __init__(self, max_combinations=3):
|
||||||
|
self.max_combinations = max_combinations
|
||||||
|
|
||||||
|
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||||
|
for k in range(2, self.max_combinations + 1):
|
||||||
|
for combo in combinations(candidates, k):
|
||||||
|
total = sum(c.amount for c in combo)
|
||||||
|
if abs(total - bank_amount) < AMOUNT_TOLERANCE:
|
||||||
|
return MatchResult(
|
||||||
|
picked_ids=[c.id for c in combo],
|
||||||
|
confidence=0.85,
|
||||||
|
residual=0.0,
|
||||||
|
strategy_name=f'multi_invoice_{k}',
|
||||||
|
)
|
||||||
|
return MatchResult(strategy_name='multi_invoice')
|
||||||
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
44
fusion_accounting_bank_rec/services/memo_tokenizer.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Extract searchable tokens from Canadian bank statement memos.
|
||||||
|
|
||||||
|
Handles common memo formats from RBC, TD, Scotia, BMO, plus generic
|
||||||
|
cheque-number and reference-number patterns. Output is normalized
|
||||||
|
(uppercase, alphanumeric) for case-insensitive matching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
REF_PATTERNS = [
|
||||||
|
(re.compile(r'\b(REF|REFERENCE)\s*#?\s*(\d+)\b', re.I), r'REF\2'),
|
||||||
|
(re.compile(r'\b(CHQ|CHEQUE|CHECK)\s*#?\s*(\d+)\b', re.I), r'CHEQUE\2'),
|
||||||
|
(re.compile(r'\b(INV|INVOICE)\s*#?\s*(\d+)\b', re.I), r'INV\2'),
|
||||||
|
]
|
||||||
|
|
||||||
|
MIN_TOKEN_LENGTH = 2
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize_memo(memo: str | None) -> list[str]:
|
||||||
|
"""Return list of normalized tokens from a bank memo.
|
||||||
|
|
||||||
|
Empty/None input returns []. Order preserved (first occurrence wins
|
||||||
|
for de-duplication)."""
|
||||||
|
if not memo:
|
||||||
|
return []
|
||||||
|
|
||||||
|
text = memo.upper()
|
||||||
|
for pattern, replacement in REF_PATTERNS:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
|
||||||
|
text = re.sub(r'[^A-Z0-9]+', ' ', text)
|
||||||
|
raw_tokens = text.split()
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
tokens = []
|
||||||
|
for tok in raw_tokens:
|
||||||
|
if len(tok) < MIN_TOKEN_LENGTH:
|
||||||
|
continue
|
||||||
|
if tok in seen:
|
||||||
|
continue
|
||||||
|
seen.add(tok)
|
||||||
|
tokens.append(tok)
|
||||||
|
|
||||||
|
return tokens
|
||||||
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Aggregate per-partner reconciliation patterns from precedent rows.
|
||||||
|
|
||||||
|
Computes typical amount range, cadence, preferred strategy, common memo
|
||||||
|
tokens. Output is a dict suitable for create/write on fusion.reconcile.pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from statistics import median
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pattern_for_partner(env, *, company_id, partner_id) -> dict:
|
||||||
|
"""Compute the pattern aggregate for one (company, partner) pair.
|
||||||
|
|
||||||
|
Returns vals dict suitable for env['fusion.reconcile.pattern'].create()."""
|
||||||
|
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||||
|
precedents = Precedent.search([
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
], order='reconciled_at desc', limit=200)
|
||||||
|
|
||||||
|
if not precedents:
|
||||||
|
return {
|
||||||
|
'company_id': company_id,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
'reconcile_count': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
amounts = sorted(precedents.mapped('amount'))
|
||||||
|
counts = precedents.mapped('matched_move_line_count')
|
||||||
|
|
||||||
|
single_count = sum(1 for c in counts if c == 1)
|
||||||
|
multi_count = sum(1 for c in counts if c > 1)
|
||||||
|
if multi_count > single_count:
|
||||||
|
pref_strategy = 'multi_invoice'
|
||||||
|
elif _amounts_concentrated(amounts):
|
||||||
|
pref_strategy = 'exact_amount'
|
||||||
|
else:
|
||||||
|
pref_strategy = 'fifo'
|
||||||
|
|
||||||
|
reconcile_dates = sorted([p.reconciled_at for p in precedents if p.reconciled_at])
|
||||||
|
if len(reconcile_dates) >= 2:
|
||||||
|
deltas = [(reconcile_dates[i+1] - reconcile_dates[i]).days
|
||||||
|
for i in range(len(reconcile_dates) - 1)]
|
||||||
|
cadence = sum(deltas) / len(deltas) if deltas else 0.0
|
||||||
|
else:
|
||||||
|
cadence = 0.0
|
||||||
|
|
||||||
|
token_counter = Counter()
|
||||||
|
for p in precedents:
|
||||||
|
if p.memo_tokens:
|
||||||
|
for tok in p.memo_tokens.split(','):
|
||||||
|
token_counter[tok.strip()] += 1
|
||||||
|
# Keep tokens appearing in >=30% of precedents (min floor of 2 occurrences)
|
||||||
|
threshold = max(2, len(precedents) * 0.3)
|
||||||
|
common_tokens = ','.join(t for t, c in token_counter.most_common() if c >= threshold)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_id': company_id,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
'reconcile_count': len(precedents),
|
||||||
|
'typical_amount_range': f"${min(amounts):,.2f} – ${max(amounts):,.2f} (median ${median(amounts):,.2f})",
|
||||||
|
'typical_cadence_days': round(cadence, 1),
|
||||||
|
'pref_strategy': pref_strategy,
|
||||||
|
'common_memo_tokens': common_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _amounts_concentrated(amounts: list[float]) -> bool:
|
||||||
|
"""True if amounts cluster around a few values (suggests exact-amount strategy)."""
|
||||||
|
if len(amounts) < 3:
|
||||||
|
return True
|
||||||
|
med = median(amounts)
|
||||||
|
within_5pct = sum(1 for a in amounts if abs(a - med) / max(med, 1) < 0.05)
|
||||||
|
return within_5pct / len(amounts) >= 0.6
|
||||||
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
62
fusion_accounting_bank_rec/services/precedent_lookup.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""K-nearest precedent search.
|
||||||
|
|
||||||
|
Given a new bank line, find the most similar past reconciliations for
|
||||||
|
ranking + confidence scoring. Distance metric: amount delta (primary),
|
||||||
|
date recency (secondary), memo token overlap (tertiary).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrecedentMatch:
|
||||||
|
precedent_id: int
|
||||||
|
amount: float
|
||||||
|
memo_tokens: str
|
||||||
|
matched_move_line_count: int
|
||||||
|
similarity_score: float
|
||||||
|
|
||||||
|
|
||||||
|
AMOUNT_TOLERANCE_PCT = 0.01 # 1% tolerance for "near" amount
|
||||||
|
|
||||||
|
|
||||||
|
def find_nearest_precedents(env, *, partner_id, amount, k=5, memo_tokens=None):
|
||||||
|
"""Return up to k most-similar precedents for a partner+amount.
|
||||||
|
|
||||||
|
Indexed query: filters by partner first (cheap), then ranks by
|
||||||
|
amount distance + memo overlap. Sub-50ms for typical Westin volume."""
|
||||||
|
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||||
|
|
||||||
|
tolerance = max(amount * AMOUNT_TOLERANCE_PCT, 1.00)
|
||||||
|
candidates = Precedent.search([
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
('amount', '>=', amount - tolerance),
|
||||||
|
('amount', '<=', amount + tolerance),
|
||||||
|
], limit=k * 4, order='reconciled_at desc')
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for p in candidates:
|
||||||
|
amount_score = 1.0 - min(abs(p.amount - amount) / max(amount, 1), 1.0)
|
||||||
|
memo_score = _memo_overlap(p.memo_tokens, memo_tokens) if memo_tokens else 0.5
|
||||||
|
similarity = (amount_score * 0.7) + (memo_score * 0.3)
|
||||||
|
results.append(PrecedentMatch(
|
||||||
|
precedent_id=p.id,
|
||||||
|
amount=p.amount,
|
||||||
|
memo_tokens=p.memo_tokens or '',
|
||||||
|
matched_move_line_count=p.matched_move_line_count,
|
||||||
|
similarity_score=similarity,
|
||||||
|
))
|
||||||
|
|
||||||
|
results.sort(key=lambda r: -r.similarity_score)
|
||||||
|
return results[:k]
|
||||||
|
|
||||||
|
|
||||||
|
def _memo_overlap(precedent_tokens_str, new_tokens) -> float:
|
||||||
|
"""Jaccard similarity between two token sets."""
|
||||||
|
if not precedent_tokens_str or not new_tokens:
|
||||||
|
return 0.0
|
||||||
|
precedent_set = set(precedent_tokens_str.split(','))
|
||||||
|
new_set = set(new_tokens) if not isinstance(new_tokens, set) else new_tokens
|
||||||
|
if not precedent_set and not new_set:
|
||||||
|
return 0.0
|
||||||
|
return len(precedent_set & new_set) / len(precedent_set | new_set)
|
||||||
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
BIN
fusion_accounting_bank_rec/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
11
fusion_accounting_bank_rec/tests/__init__.py
Normal file
11
fusion_accounting_bank_rec/tests/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from . import test_memo_tokenizer
|
||||||
|
from . import test_exchange_diff
|
||||||
|
from . import test_matching_strategies
|
||||||
|
from . import test_ai_suggestion_lifecycle
|
||||||
|
from . import test_precedent_lookup
|
||||||
|
from . import test_pattern_extraction
|
||||||
|
from . import test_confidence_scoring
|
||||||
|
from . import test_reconcile_engine_unit
|
||||||
|
from . import test_reconcile_engine_property
|
||||||
|
from . import test_factories
|
||||||
|
from . import test_reconcile_engine_integration
|
||||||
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Test data factories for fusion_accounting_bank_rec.
|
||||||
|
|
||||||
|
Provides recordset builders for use across all test files. Sane defaults
|
||||||
|
let tests be readable: `make_bank_line(env, amount=100, partner=p)` instead
|
||||||
|
of 30 lines of recordset setup.
|
||||||
|
|
||||||
|
These factories work against the real Odoo registry — they exercise the
|
||||||
|
same code paths as production. Each factory is idempotent in the sense
|
||||||
|
that calling it multiple times returns separate records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from odoo import fields
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Bank journal + statements
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def make_bank_journal(env, *, name='Test Bank', code=None):
|
||||||
|
"""Create a bank journal. `code` defaults to first 5 chars of `name`."""
|
||||||
|
code = code or name[:5].upper().replace(' ', '')
|
||||||
|
return env['account.journal'].create({
|
||||||
|
'name': name,
|
||||||
|
'type': 'bank',
|
||||||
|
'code': code,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
|
||||||
|
"""Create a bank statement. Auto-creates a bank journal if not provided."""
|
||||||
|
journal = journal or make_bank_journal(env)
|
||||||
|
return env['account.bank.statement'].create({
|
||||||
|
'name': name,
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'date': date_ or date.today(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||||
|
partner=None, memo='Test line', date_=None):
|
||||||
|
"""Create a bank statement line. Creates statement if not provided.
|
||||||
|
|
||||||
|
Most-common factory in tests. Defaults give a $100 line with no partner."""
|
||||||
|
if not statement:
|
||||||
|
statement = make_bank_statement(env, journal=journal, date_=date_)
|
||||||
|
return env['account.bank.statement.line'].create({
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'journal_id': statement.journal_id.id,
|
||||||
|
'date': date_ or date.today(),
|
||||||
|
'payment_ref': memo,
|
||||||
|
'amount': amount,
|
||||||
|
'partner_id': partner.id if partner else False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Invoices + journal items
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _ensure_test_product(env):
|
||||||
|
"""Get or create a service product suitable for invoice lines."""
|
||||||
|
product = env['product.product'].search([('type', '=', 'service')], limit=1)
|
||||||
|
if not product:
|
||||||
|
product = env['product.product'].create({
|
||||||
|
'name': 'Fusion Test Service',
|
||||||
|
'type': 'service',
|
||||||
|
})
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def make_invoice(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||||
|
product=None, posted=True):
|
||||||
|
"""Create a customer invoice (out_invoice). Posted by default."""
|
||||||
|
product = product or _ensure_test_product(env)
|
||||||
|
vals = {
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'invoice_date': date_ or date.today(),
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': product.id,
|
||||||
|
'name': 'Test invoice line',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': amount,
|
||||||
|
})],
|
||||||
|
}
|
||||||
|
if currency:
|
||||||
|
vals['currency_id'] = currency.id
|
||||||
|
move = env['account.move'].create(vals)
|
||||||
|
if posted:
|
||||||
|
move.action_post()
|
||||||
|
return move
|
||||||
|
|
||||||
|
|
||||||
|
def make_vendor_bill(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||||
|
product=None, posted=True):
|
||||||
|
"""Create a vendor bill (in_invoice). Posted by default."""
|
||||||
|
product = product or _ensure_test_product(env)
|
||||||
|
vals = {
|
||||||
|
'move_type': 'in_invoice',
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'invoice_date': date_ or date.today(),
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': product.id,
|
||||||
|
'name': 'Test bill line',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': amount,
|
||||||
|
})],
|
||||||
|
}
|
||||||
|
if currency:
|
||||||
|
vals['currency_id'] = currency.id
|
||||||
|
move = env['account.move'].create(vals)
|
||||||
|
if posted:
|
||||||
|
move.action_post()
|
||||||
|
return move
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Suggestions + patterns + precedents (fusion-specific)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def make_suggestion(env, *, statement_line, candidate_move_lines=None,
|
||||||
|
confidence=0.92, rank=1, reasoning='Test suggestion',
|
||||||
|
state='pending'):
|
||||||
|
"""Create a fusion.reconcile.suggestion against a bank line."""
|
||||||
|
candidate_ids = candidate_move_lines.ids if candidate_move_lines else []
|
||||||
|
return env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': env.company.id,
|
||||||
|
'statement_line_id': statement_line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, candidate_ids)],
|
||||||
|
'confidence': confidence,
|
||||||
|
'rank': rank,
|
||||||
|
'reasoning': reasoning,
|
||||||
|
'state': state,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_pattern(env, *, partner, reconcile_count=10, pref_strategy='exact_amount',
|
||||||
|
typical_cadence_days=14.0, common_memo_tokens='RBC,ETF'):
|
||||||
|
"""Create a fusion.reconcile.pattern for a partner."""
|
||||||
|
return env['fusion.reconcile.pattern'].create({
|
||||||
|
'company_id': env.company.id,
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'reconcile_count': reconcile_count,
|
||||||
|
'pref_strategy': pref_strategy,
|
||||||
|
'typical_cadence_days': typical_cadence_days,
|
||||||
|
'common_memo_tokens': common_memo_tokens,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_precedent(env, *, partner, amount=1847.50, days_ago=14,
|
||||||
|
memo_tokens='RBC,ETF,REF', count=1, source='manual'):
|
||||||
|
"""Create a fusion.reconcile.precedent."""
|
||||||
|
return env['fusion.reconcile.precedent'].create({
|
||||||
|
'company_id': env.company.id,
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'amount': amount,
|
||||||
|
'currency_id': env.company.currency_id.id,
|
||||||
|
'date': date.today() - timedelta(days=days_ago),
|
||||||
|
'memo_tokens': memo_tokens,
|
||||||
|
'matched_move_line_count': count,
|
||||||
|
'reconciled_at': fields.Datetime.now(),
|
||||||
|
'source': source,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Convenience composite — bank line + matching invoice ready to reconcile
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def make_reconcileable_pair(env, *, amount=100.00, partner=None, date_=None):
|
||||||
|
"""Create a bank line + a customer invoice with the same partner+amount.
|
||||||
|
Returns (bank_line, invoice_recv_lines) ready to pass to engine.reconcile_one().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(bank_line, invoice_receivable_lines) tuple
|
||||||
|
"""
|
||||||
|
if not partner:
|
||||||
|
partner = env['res.partner'].create({'name': 'Reconcile Test Partner'})
|
||||||
|
invoice = make_invoice(env, partner=partner, amount=amount, date_=date_)
|
||||||
|
bank_line = make_bank_line(env, amount=amount, partner=partner, date_=date_)
|
||||||
|
recv_lines = invoice.line_ids.filtered(
|
||||||
|
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||||
|
return (bank_line, recv_lines)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestSuggestionLifecycle(TransactionCase):
|
||||||
|
"""The fusion.reconcile.suggestion state machine + computed band."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
journal = self.env['account.journal'].create({
|
||||||
|
'name': 'Test Bank Suggestion',
|
||||||
|
'type': 'bank',
|
||||||
|
'code': 'TBSG',
|
||||||
|
})
|
||||||
|
statement = self.env['account.bank.statement'].create({
|
||||||
|
'name': 'Test Statement',
|
||||||
|
'journal_id': journal.id,
|
||||||
|
})
|
||||||
|
self.line = self.env['account.bank.statement.line'].create({
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'date': '2026-04-19',
|
||||||
|
'payment_ref': 'Test for suggestion',
|
||||||
|
'amount': 100.00,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _make_suggestion(self, confidence=0.92, **vals):
|
||||||
|
defaults = {
|
||||||
|
'company_id': self.env.company.id,
|
||||||
|
'statement_line_id': self.line.id,
|
||||||
|
'confidence': confidence,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'Test',
|
||||||
|
}
|
||||||
|
defaults.update(vals)
|
||||||
|
return self.env['fusion.reconcile.suggestion'].create(defaults)
|
||||||
|
|
||||||
|
def test_compute_band_high(self):
|
||||||
|
sug = self._make_suggestion(confidence=0.96)
|
||||||
|
self.assertEqual(sug.confidence_band, 'high')
|
||||||
|
|
||||||
|
def test_compute_band_medium(self):
|
||||||
|
sug = self._make_suggestion(confidence=0.75)
|
||||||
|
self.assertEqual(sug.confidence_band, 'medium')
|
||||||
|
|
||||||
|
def test_compute_band_low(self):
|
||||||
|
sug = self._make_suggestion(confidence=0.55)
|
||||||
|
self.assertEqual(sug.confidence_band, 'low')
|
||||||
|
|
||||||
|
def test_compute_band_none(self):
|
||||||
|
sug = self._make_suggestion(confidence=0.30)
|
||||||
|
self.assertEqual(sug.confidence_band, 'none')
|
||||||
|
|
||||||
|
def test_default_state_is_pending(self):
|
||||||
|
sug = self._make_suggestion()
|
||||||
|
self.assertEqual(sug.state, 'pending')
|
||||||
|
|
||||||
|
def test_state_transition_to_accepted(self):
|
||||||
|
sug = self._make_suggestion()
|
||||||
|
sug.write({
|
||||||
|
'state': 'accepted',
|
||||||
|
'accepted_at': '2026-04-19 12:00:00',
|
||||||
|
'accepted_by': self.env.user.id,
|
||||||
|
})
|
||||||
|
self.assertEqual(sug.state, 'accepted')
|
||||||
|
self.assertTrue(sug.accepted_at)
|
||||||
|
self.assertEqual(sug.accepted_by, self.env.user)
|
||||||
|
|
||||||
|
def test_state_transition_to_rejected_with_reason(self):
|
||||||
|
sug = self._make_suggestion()
|
||||||
|
sug.write({
|
||||||
|
'state': 'rejected',
|
||||||
|
'rejected_at': '2026-04-19 12:05:00',
|
||||||
|
'rejected_reason': 'wrong_invoice',
|
||||||
|
})
|
||||||
|
self.assertEqual(sug.state, 'rejected')
|
||||||
|
self.assertEqual(sug.rejected_reason, 'wrong_invoice')
|
||||||
|
|
||||||
|
def test_state_transition_to_superseded(self):
|
||||||
|
sug = self._make_suggestion()
|
||||||
|
sug.write({'state': 'superseded'})
|
||||||
|
self.assertEqual(sug.state, 'superseded')
|
||||||
|
|
||||||
|
def test_currency_id_relates_to_line(self):
|
||||||
|
sug = self._make_suggestion()
|
||||||
|
self.assertEqual(sug.currency_id, self.line.currency_id)
|
||||||
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from datetime import date, timedelta, datetime
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
|
||||||
|
score_candidates, ScoredCandidate,
|
||||||
|
)
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import Candidate
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestConfidenceScoring(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'Scoring Test Partner'})
|
||||||
|
self.company = self.env.company
|
||||||
|
self.currency = self.env.ref('base.CAD')
|
||||||
|
|
||||||
|
self.journal = self.env['account.journal'].create({
|
||||||
|
'name': 'Test Bank Scoring',
|
||||||
|
'type': 'bank',
|
||||||
|
'code': 'TBSC',
|
||||||
|
})
|
||||||
|
statement = self.env['account.bank.statement'].create({
|
||||||
|
'name': 'Test Statement',
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
})
|
||||||
|
self.line = self.env['account.bank.statement.line'].create({
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'date': date.today(),
|
||||||
|
'payment_ref': 'RBC ETF DEP REF 4831',
|
||||||
|
'amount': 1847.50,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _candidate(self, id_, amount, age_days=10):
|
||||||
|
return Candidate(id=id_, amount=amount, partner_id=self.partner.id, age_days=age_days)
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_candidates(self):
|
||||||
|
result = score_candidates(self.env, statement_line=self.line, candidates=[], k=5)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_statement_line(self):
|
||||||
|
result = score_candidates(self.env, statement_line=None,
|
||||||
|
candidates=[self._candidate(1, 100)], k=5)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_amount_exact_dominates(self):
|
||||||
|
candidates = [
|
||||||
|
self._candidate(1, 1847.50),
|
||||||
|
self._candidate(2, 1800.00),
|
||||||
|
]
|
||||||
|
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||||
|
use_ai=False)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0].candidate_id, 1)
|
||||||
|
self.assertGreater(result[0].confidence, result[1].confidence)
|
||||||
|
self.assertGreater(result[0].score_amount_match, 0.99)
|
||||||
|
|
||||||
|
def test_returns_top_k(self):
|
||||||
|
candidates = [self._candidate(i, 1847.50 - i) for i in range(10)]
|
||||||
|
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=3,
|
||||||
|
use_ai=False)
|
||||||
|
self.assertEqual(len(result), 3)
|
||||||
|
|
||||||
|
def test_no_ai_provider_returns_statistical_only(self):
|
||||||
|
"""When no AI provider config, score_ai_rerank stays at 0.0."""
|
||||||
|
self.env['ir.config_parameter'].sudo().search([
|
||||||
|
('key', 'in', ['fusion_accounting.provider.bank_rec_suggest',
|
||||||
|
'fusion_accounting.provider.default'])
|
||||||
|
]).unlink()
|
||||||
|
candidates = [self._candidate(1, 1847.50)]
|
||||||
|
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||||
|
use_ai=True)
|
||||||
|
self.assertEqual(result[0].score_ai_rerank, 0.0)
|
||||||
|
|
||||||
|
def test_use_ai_false_skips_ai_rerank(self):
|
||||||
|
candidates = [self._candidate(1, 1847.50)]
|
||||||
|
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
|
||||||
|
use_ai=False)
|
||||||
|
self.assertEqual(result[0].score_ai_rerank, 0.0)
|
||||||
|
|
||||||
|
def test_pattern_match_boosts_confidence(self):
|
||||||
|
"""When the partner has a matching pattern, confidence is higher than no-pattern case."""
|
||||||
|
self.env['fusion.reconcile.pattern'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'reconcile_count': 10,
|
||||||
|
'pref_strategy': 'exact_amount',
|
||||||
|
})
|
||||||
|
candidates = [self._candidate(1, 1847.50)]
|
||||||
|
with_pattern = score_candidates(self.env, statement_line=self.line,
|
||||||
|
candidates=candidates, k=5, use_ai=False)
|
||||||
|
|
||||||
|
other_partner = self.env['res.partner'].create({'name': 'No Pattern Partner'})
|
||||||
|
self.line.write({'partner_id': other_partner.id})
|
||||||
|
other_candidates = [Candidate(id=1, amount=1847.50, partner_id=other_partner.id, age_days=10)]
|
||||||
|
without_pattern = score_candidates(self.env, statement_line=self.line,
|
||||||
|
candidates=other_candidates, k=5, use_ai=False)
|
||||||
|
|
||||||
|
self.assertGreater(with_pattern[0].score_partner_pattern,
|
||||||
|
without_pattern[0].score_partner_pattern - 0.001)
|
||||||
56
fusion_accounting_bank_rec/tests/test_exchange_diff.py
Normal file
56
fusion_accounting_bank_rec/tests/test_exchange_diff.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import (
|
||||||
|
compute_exchange_diff, ExchangeDiffResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestExchangeDiff(TransactionCase):
|
||||||
|
|
||||||
|
def test_no_diff_when_currencies_match_and_rates_match(self):
|
||||||
|
result = compute_exchange_diff(
|
||||||
|
line_amount=100.00, line_currency_code='CAD',
|
||||||
|
against_amount=100.00, against_currency_code='CAD',
|
||||||
|
line_rate=1.0, against_rate=1.0,
|
||||||
|
)
|
||||||
|
self.assertFalse(result.needs_diff_move)
|
||||||
|
self.assertEqual(result.diff_amount, 0.0)
|
||||||
|
|
||||||
|
def test_diff_when_rates_differ_same_currency(self):
|
||||||
|
"""USD invoice posted at 1.35, USD bank line settled at 1.40 -> diff exists.
|
||||||
|
100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain."""
|
||||||
|
result = compute_exchange_diff(
|
||||||
|
line_amount=100.00, line_currency_code='USD',
|
||||||
|
against_amount=100.00, against_currency_code='USD',
|
||||||
|
line_rate=1.40, against_rate=1.35,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.needs_diff_move)
|
||||||
|
self.assertAlmostEqual(result.diff_amount, 5.00, places=2)
|
||||||
|
|
||||||
|
def test_diff_negative_when_rate_dropped(self):
|
||||||
|
"""USD invoice at 1.40, settled at 1.35 -> loss"""
|
||||||
|
result = compute_exchange_diff(
|
||||||
|
line_amount=100.00, line_currency_code='USD',
|
||||||
|
against_amount=100.00, against_currency_code='USD',
|
||||||
|
line_rate=1.35, against_rate=1.40,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.needs_diff_move)
|
||||||
|
self.assertAlmostEqual(result.diff_amount, -5.00, places=2)
|
||||||
|
|
||||||
|
def test_company_amounts_computed_correctly(self):
|
||||||
|
result = compute_exchange_diff(
|
||||||
|
line_amount=100.00, line_currency_code='USD',
|
||||||
|
against_amount=100.00, against_currency_code='USD',
|
||||||
|
line_rate=1.40, against_rate=1.35,
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(result.line_company_amount, 140.00, places=2)
|
||||||
|
self.assertAlmostEqual(result.against_company_amount, 135.00, places=2)
|
||||||
|
|
||||||
|
def test_tolerance_handles_rounding_noise(self):
|
||||||
|
"""Tiny FX rounding under 0.005 should NOT trigger a diff move."""
|
||||||
|
result = compute_exchange_diff(
|
||||||
|
line_amount=100.00, line_currency_code='USD',
|
||||||
|
against_amount=100.00, against_currency_code='USD',
|
||||||
|
line_rate=1.40000, against_rate=1.40003, # 0.003 cent diff
|
||||||
|
)
|
||||||
|
self.assertFalse(result.needs_diff_move)
|
||||||
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Smoke tests verifying the factories produce usable records.
|
||||||
|
|
||||||
|
Not testing factory correctness exhaustively — just that each helper
|
||||||
|
returns a record of the expected type with the expected basic state."""
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
from . import _factories as f
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFactories(TransactionCase):
|
||||||
|
|
||||||
|
def test_make_bank_journal(self):
|
||||||
|
journal = f.make_bank_journal(self.env)
|
||||||
|
self.assertEqual(journal._name, 'account.journal')
|
||||||
|
self.assertEqual(journal.type, 'bank')
|
||||||
|
|
||||||
|
def test_make_bank_statement(self):
|
||||||
|
statement = f.make_bank_statement(self.env)
|
||||||
|
self.assertEqual(statement._name, 'account.bank.statement')
|
||||||
|
self.assertTrue(statement.journal_id)
|
||||||
|
|
||||||
|
def test_make_bank_line(self):
|
||||||
|
line = f.make_bank_line(self.env, amount=250.00, memo='Smoke memo')
|
||||||
|
self.assertEqual(line._name, 'account.bank.statement.line')
|
||||||
|
self.assertEqual(line.amount, 250.00)
|
||||||
|
self.assertEqual(line.payment_ref, 'Smoke memo')
|
||||||
|
self.assertFalse(line.is_reconciled)
|
||||||
|
|
||||||
|
def test_make_bank_line_with_partner(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Factory Partner'})
|
||||||
|
line = f.make_bank_line(self.env, partner=partner, amount=500)
|
||||||
|
self.assertEqual(line.partner_id, partner)
|
||||||
|
|
||||||
|
def test_make_invoice_posted(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Invoice Partner'})
|
||||||
|
invoice = f.make_invoice(self.env, partner=partner, amount=300)
|
||||||
|
self.assertEqual(invoice._name, 'account.move')
|
||||||
|
self.assertEqual(invoice.move_type, 'out_invoice')
|
||||||
|
self.assertEqual(invoice.state, 'posted')
|
||||||
|
self.assertAlmostEqual(invoice.amount_total, 300, places=2)
|
||||||
|
|
||||||
|
def test_make_vendor_bill_posted(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Vendor Partner'})
|
||||||
|
bill = f.make_vendor_bill(self.env, partner=partner, amount=400)
|
||||||
|
self.assertEqual(bill.move_type, 'in_invoice')
|
||||||
|
self.assertEqual(bill.state, 'posted')
|
||||||
|
|
||||||
|
def test_make_suggestion(self):
|
||||||
|
line = f.make_bank_line(self.env, amount=100)
|
||||||
|
sug = f.make_suggestion(self.env, statement_line=line, confidence=0.85)
|
||||||
|
self.assertEqual(sug._name, 'fusion.reconcile.suggestion')
|
||||||
|
self.assertEqual(sug.confidence, 0.85)
|
||||||
|
self.assertEqual(sug.state, 'pending')
|
||||||
|
|
||||||
|
def test_make_pattern(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Pattern Partner'})
|
||||||
|
pattern = f.make_pattern(self.env, partner=partner, reconcile_count=20)
|
||||||
|
self.assertEqual(pattern._name, 'fusion.reconcile.pattern')
|
||||||
|
self.assertEqual(pattern.reconcile_count, 20)
|
||||||
|
|
||||||
|
def test_make_precedent(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Precedent Partner'})
|
||||||
|
precedent = f.make_precedent(self.env, partner=partner, amount=999.99)
|
||||||
|
self.assertEqual(precedent._name, 'fusion.reconcile.precedent')
|
||||||
|
self.assertEqual(precedent.amount, 999.99)
|
||||||
|
self.assertEqual(precedent.source, 'manual')
|
||||||
|
|
||||||
|
def test_make_reconcileable_pair(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=750)
|
||||||
|
self.assertEqual(bank_line.amount, 750.00)
|
||||||
|
self.assertGreater(len(recv_lines), 0)
|
||||||
|
self.assertAlmostEqual(sum(recv_lines.mapped('amount_residual')), 750, places=2)
|
||||||
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
||||||
|
Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestAmountExactStrategy(TransactionCase):
|
||||||
|
|
||||||
|
def test_picks_exact_amount(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=100.00, partner_id=42, age_days=20),
|
||||||
|
Candidate(id=3, amount=100.50, partner_id=42, age_days=5),
|
||||||
|
]
|
||||||
|
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(result.picked_ids, [2])
|
||||||
|
self.assertEqual(result.confidence, 1.0)
|
||||||
|
|
||||||
|
def test_no_match_when_no_exact(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=99.99, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=100.50, partner_id=42, age_days=20),
|
||||||
|
]
|
||||||
|
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(result.picked_ids, [])
|
||||||
|
|
||||||
|
def test_picks_oldest_when_multiple_exact(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=100.00, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest
|
||||||
|
Candidate(id=3, amount=100.00, partner_id=42, age_days=20),
|
||||||
|
]
|
||||||
|
result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(result.picked_ids, [2])
|
||||||
|
|
||||||
|
def test_handles_empty_candidates(self):
|
||||||
|
result = AmountExactStrategy().match(bank_amount=100.00, candidates=[])
|
||||||
|
self.assertEqual(result.picked_ids, [])
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestFIFOStrategy(TransactionCase):
|
||||||
|
|
||||||
|
def test_picks_oldest_first(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=50.00, partner_id=42, age_days=30),
|
||||||
|
Candidate(id=3, amount=50.00, partner_id=42, age_days=20),
|
||||||
|
]
|
||||||
|
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100
|
||||||
|
|
||||||
|
def test_handles_partial_payment(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=200.00, partner_id=42, age_days=30),
|
||||||
|
]
|
||||||
|
result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual
|
||||||
|
self.assertEqual(result.residual, -100.00) # over-allocated; engine handles
|
||||||
|
|
||||||
|
def test_handles_empty_candidates(self):
|
||||||
|
result = FIFOStrategy().match(bank_amount=100.00, candidates=[])
|
||||||
|
self.assertEqual(result.picked_ids, [])
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestMultiInvoiceStrategy(TransactionCase):
|
||||||
|
|
||||||
|
def test_finds_smallest_set_summing_to_amount(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=30.00, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=40.00, partner_id=42, age_days=15),
|
||||||
|
Candidate(id=3, amount=30.00, partner_id=42, age_days=20),
|
||||||
|
Candidate(id=4, amount=70.00, partner_id=42, age_days=25),
|
||||||
|
]
|
||||||
|
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||||
|
bank_amount=100.00, candidates=candidates)
|
||||||
|
# Two-element solutions exist (e.g., {3,4}=100). Strategy should pick a 2-set.
|
||||||
|
self.assertEqual(len(result.picked_ids), 2)
|
||||||
|
# The picked set should sum to 100
|
||||||
|
picked_amounts = [c.amount for c in candidates if c.id in result.picked_ids]
|
||||||
|
self.assertAlmostEqual(sum(picked_amounts), 100.00, places=2)
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_combination_sums(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=15.00, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=25.00, partner_id=42, age_days=15),
|
||||||
|
]
|
||||||
|
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||||
|
bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(result.picked_ids, [])
|
||||||
|
|
||||||
|
def test_respects_max_combinations(self):
|
||||||
|
# Many small invoices — only combinations of ≤3 items considered
|
||||||
|
candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i)
|
||||||
|
for i in range(1, 11)]
|
||||||
|
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||||
|
bank_amount=100.00, candidates=candidates)
|
||||||
|
# Can't make 100 with ≤3 items of $10 each
|
||||||
|
self.assertEqual(result.picked_ids, [])
|
||||||
|
|
||||||
|
def test_strategy_name_includes_combination_size(self):
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=1, amount=50.00, partner_id=42, age_days=10),
|
||||||
|
Candidate(id=2, amount=50.00, partner_id=42, age_days=20),
|
||||||
|
]
|
||||||
|
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||||
|
bank_amount=100.00, candidates=candidates)
|
||||||
|
self.assertEqual(set(result.picked_ids), {1, 2})
|
||||||
|
self.assertIn('multi_invoice', result.strategy_name)
|
||||||
42
fusion_accounting_bank_rec/tests/test_memo_tokenizer.py
Normal file
42
fusion_accounting_bank_rec/tests/test_memo_tokenizer.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestMemoTokenizer(TransactionCase):
|
||||||
|
|
||||||
|
def test_extracts_rbc_etf_reference(self):
|
||||||
|
tokens = tokenize_memo("RBC ETF DEP REF 4831")
|
||||||
|
self.assertIn('RBC', tokens)
|
||||||
|
self.assertIn('ETF', tokens)
|
||||||
|
self.assertIn('REF4831', tokens)
|
||||||
|
|
||||||
|
def test_extracts_cheque_number(self):
|
||||||
|
tokens = tokenize_memo("CHEQUE 4827 - WESTIN PLATING")
|
||||||
|
self.assertIn('CHEQUE4827', tokens)
|
||||||
|
self.assertIn('WESTIN', tokens)
|
||||||
|
self.assertIn('PLATING', tokens)
|
||||||
|
|
||||||
|
def test_strips_noise_tokens(self):
|
||||||
|
tokens = tokenize_memo("PAYMENT - INV - DEP - 12345")
|
||||||
|
self.assertNotIn('-', tokens)
|
||||||
|
self.assertEqual([t for t in tokens if len(t) <= 1], [])
|
||||||
|
|
||||||
|
def test_handles_empty_memo(self):
|
||||||
|
self.assertEqual(tokenize_memo(""), [])
|
||||||
|
self.assertEqual(tokenize_memo(None), [])
|
||||||
|
|
||||||
|
def test_canadian_french_memo(self):
|
||||||
|
tokens = tokenize_memo("PAIEMENT VIREMENT BANCAIRE")
|
||||||
|
self.assertIn('PAIEMENT', tokens)
|
||||||
|
self.assertIn('VIREMENT', tokens)
|
||||||
|
|
||||||
|
def test_normalises_case(self):
|
||||||
|
tokens = tokenize_memo("rbc etf dep ref 4831")
|
||||||
|
self.assertIn('RBC', tokens)
|
||||||
|
|
||||||
|
def test_handles_special_characters(self):
|
||||||
|
tokens = tokenize_memo("RBC*PAYMENT/REF#4831")
|
||||||
|
self.assertIn('RBC', tokens)
|
||||||
|
self.assertIn('PAYMENT', tokens)
|
||||||
|
self.assertIn('REF4831', tokens)
|
||||||
73
fusion_accounting_bank_rec/tests/test_pattern_extraction.py
Normal file
73
fusion_accounting_bank_rec/tests/test_pattern_extraction.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import date, timedelta, datetime
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import (
|
||||||
|
extract_pattern_for_partner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestPatternExtractor(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'Pattern Test Partner'})
|
||||||
|
self.currency = self.env.ref('base.CAD')
|
||||||
|
self.company = self.env.company
|
||||||
|
|
||||||
|
def _make_precedent(self, *, amount, days_ago, memo='RBC,ETF', count=1, source='manual'):
|
||||||
|
return self.env['fusion.reconcile.precedent'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'amount': amount,
|
||||||
|
'currency_id': self.currency.id,
|
||||||
|
'date': date.today() - timedelta(days=days_ago),
|
||||||
|
'memo_tokens': memo,
|
||||||
|
'matched_move_line_count': count,
|
||||||
|
'reconciled_at': datetime.now() - timedelta(days=days_ago),
|
||||||
|
'source': source,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_extracts_typical_amount_range(self):
|
||||||
|
for d in [10, 24, 38, 52]:
|
||||||
|
self._make_precedent(amount=1847.50, days_ago=d)
|
||||||
|
|
||||||
|
pattern_vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||||
|
self.assertIn('typical_amount_range', pattern_vals)
|
||||||
|
self.assertEqual(pattern_vals['reconcile_count'], 4)
|
||||||
|
|
||||||
|
def test_detects_exact_amount_strategy(self):
|
||||||
|
for d in range(0, 56, 14):
|
||||||
|
self._make_precedent(amount=1847.50, days_ago=d, count=1)
|
||||||
|
pattern_vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||||
|
self.assertEqual(pattern_vals['pref_strategy'], 'exact_amount')
|
||||||
|
|
||||||
|
def test_detects_multi_invoice_strategy(self):
|
||||||
|
for d in range(0, 56, 14):
|
||||||
|
self._make_precedent(amount=2500.00, days_ago=d, count=3)
|
||||||
|
pattern_vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||||
|
self.assertEqual(pattern_vals['pref_strategy'], 'multi_invoice')
|
||||||
|
|
||||||
|
def test_computes_cadence_days(self):
|
||||||
|
for d in [0, 14, 28, 42]:
|
||||||
|
self._make_precedent(amount=1000, days_ago=d)
|
||||||
|
pattern_vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||||
|
self.assertAlmostEqual(pattern_vals['typical_cadence_days'], 14.0, delta=1)
|
||||||
|
|
||||||
|
def test_extracts_common_memo_tokens(self):
|
||||||
|
self._make_precedent(amount=1000, days_ago=10, memo='RBC,ETF,REF')
|
||||||
|
self._make_precedent(amount=1000, days_ago=24, memo='RBC,ETF,DEPOSIT')
|
||||||
|
self._make_precedent(amount=1000, days_ago=38, memo='RBC,ETF,REF')
|
||||||
|
pattern_vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=self.company.id, partner_id=self.partner.id)
|
||||||
|
self.assertIn('RBC', pattern_vals['common_memo_tokens'])
|
||||||
|
self.assertIn('ETF', pattern_vals['common_memo_tokens'])
|
||||||
|
|
||||||
|
def test_returns_zero_count_for_partner_with_no_precedents(self):
|
||||||
|
other_partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||||
|
pattern_vals = extract_pattern_for_partner(
|
||||||
|
self.env, company_id=self.company.id, partner_id=other_partner.id)
|
||||||
|
self.assertEqual(pattern_vals['reconcile_count'], 0)
|
||||||
73
fusion_accounting_bank_rec/tests/test_precedent_lookup.py
Normal file
73
fusion_accounting_bank_rec/tests/test_precedent_lookup.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import date
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import (
|
||||||
|
find_nearest_precedents, PrecedentMatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestPrecedentLookup(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create({'name': 'Precedent Lookup Partner'})
|
||||||
|
self.currency = self.env.ref('base.CAD')
|
||||||
|
self.company = self.env.company
|
||||||
|
for amt in [1847.50, 1847.50, 1800.00]:
|
||||||
|
self.env['fusion.reconcile.precedent'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'amount': amt,
|
||||||
|
'currency_id': self.currency.id,
|
||||||
|
'date': date.today(),
|
||||||
|
'memo_tokens': 'RBC,ETF,REF',
|
||||||
|
'matched_move_line_count': 1,
|
||||||
|
'source': 'manual',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_finds_amount_exact_precedents(self):
|
||||||
|
results = find_nearest_precedents(
|
||||||
|
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||||
|
amounts = [r.amount for r in results]
|
||||||
|
self.assertEqual(amounts.count(1847.50), 2)
|
||||||
|
|
||||||
|
def test_returns_empty_for_unknown_partner(self):
|
||||||
|
results = find_nearest_precedents(
|
||||||
|
self.env, partner_id=999999, amount=1847.50, k=5)
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
|
||||||
|
def test_respects_k_limit(self):
|
||||||
|
for i in range(10):
|
||||||
|
self.env['fusion.reconcile.precedent'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'amount': 1847.50,
|
||||||
|
'currency_id': self.currency.id,
|
||||||
|
'date': date.today(),
|
||||||
|
'matched_move_line_count': 1,
|
||||||
|
'source': 'manual',
|
||||||
|
})
|
||||||
|
results = find_nearest_precedents(
|
||||||
|
self.env, partner_id=self.partner.id, amount=1847.50, k=3)
|
||||||
|
self.assertEqual(len(results), 3)
|
||||||
|
|
||||||
|
def test_results_sorted_by_similarity_desc(self):
|
||||||
|
results = find_nearest_precedents(
|
||||||
|
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||||
|
if len(results) >= 2:
|
||||||
|
self.assertGreaterEqual(results[0].similarity_score, results[1].similarity_score)
|
||||||
|
|
||||||
|
def test_memo_overlap_boosts_score(self):
|
||||||
|
results_with_memo = find_nearest_precedents(
|
||||||
|
self.env, partner_id=self.partner.id, amount=1847.50, k=5,
|
||||||
|
memo_tokens=['RBC', 'ETF', 'REF'])
|
||||||
|
results_no_memo = find_nearest_precedents(
|
||||||
|
self.env, partner_id=self.partner.id, amount=1847.50, k=5)
|
||||||
|
if results_with_memo and results_no_memo:
|
||||||
|
self.assertGreaterEqual(results_with_memo[0].similarity_score,
|
||||||
|
results_no_memo[0].similarity_score - 0.001)
|
||||||
|
|
||||||
|
def test_amount_outside_tolerance_excluded(self):
|
||||||
|
results = find_nearest_precedents(
|
||||||
|
self.env, partner_id=self.partner.id, amount=2000.00, k=5)
|
||||||
|
self.assertEqual(results, [])
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Integration tests for the reconcile engine.
|
||||||
|
|
||||||
|
These tests use the test factories (_factories.py) to set up realistic
|
||||||
|
bank-line + invoice scenarios, then call engine methods and assert the
|
||||||
|
account.partial.reconcile rows produced have the right shape.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Simple 1:1 match (bank line == one invoice)
|
||||||
|
- Partial chain (one bank line < invoice amount)
|
||||||
|
- Multi-invoice consolidation (one bank line == sum of N invoices)
|
||||||
|
- Auto-strategy batch (mix of matchable and unmatchable lines)
|
||||||
|
- Suggest-then-accept flow
|
||||||
|
- Unreconcile (reverse a reconciliation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
from . import _factories as f
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'integration')
|
||||||
|
class TestReconcileSimpleMatch(TransactionCase):
|
||||||
|
"""The most common scenario: 1 bank line matched against 1 invoice exact."""
|
||||||
|
|
||||||
|
def test_simple_match_creates_partial_reconcile(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
|
||||||
|
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
|
||||||
|
self.assertGreater(len(result['partial_ids']), 0)
|
||||||
|
partial = self.env['account.partial.reconcile'].browse(result['partial_ids'])
|
||||||
|
self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2)
|
||||||
|
|
||||||
|
def test_simple_match_marks_line_reconciled(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00)
|
||||||
|
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
bank_line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertTrue(bank_line.is_reconciled)
|
||||||
|
|
||||||
|
def test_simple_match_records_precedent(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00)
|
||||||
|
partner = bank_line.partner_id
|
||||||
|
Precedent = self.env['fusion.reconcile.precedent']
|
||||||
|
before = Precedent.search_count([('partner_id', '=', partner.id)])
|
||||||
|
|
||||||
|
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
|
||||||
|
after = Precedent.search_count([('partner_id', '=', partner.id)])
|
||||||
|
self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile")
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'integration')
|
||||||
|
class TestReconcilePartialChain(TransactionCase):
|
||||||
|
"""Bank line amount < invoice amount -> partial reconcile, residual remains."""
|
||||||
|
|
||||||
|
def test_partial_reconcile_leaves_residual(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Partial Partner'})
|
||||||
|
invoice = f.make_invoice(self.env, partner=partner, amount=300.00)
|
||||||
|
recv_lines = invoice.line_ids.filtered(
|
||||||
|
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||||
|
|
||||||
|
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
|
||||||
|
self.assertGreater(len(result['partial_ids']), 0)
|
||||||
|
invoice.invalidate_recordset(['payment_state', 'amount_residual'])
|
||||||
|
self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'integration')
|
||||||
|
class TestReconcileBatch(TransactionCase):
|
||||||
|
"""Bulk reconcile: mix of matchable and unmatchable lines."""
|
||||||
|
|
||||||
|
def test_batch_reconciles_matchable_lines_only(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Batch Partner'})
|
||||||
|
# Share one journal/statement to avoid duplicate-code conflicts
|
||||||
|
# when creating multiple bank lines in the same test transaction.
|
||||||
|
shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK')
|
||||||
|
shared_statement = f.make_bank_statement(self.env, journal=shared_journal)
|
||||||
|
pairs = []
|
||||||
|
for amount in [100.00, 200.00, 300.00]:
|
||||||
|
invoice = f.make_invoice(self.env, partner=partner, amount=amount)
|
||||||
|
recv_lines = invoice.line_ids.filtered(
|
||||||
|
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||||
|
bank_line = f.make_bank_line(
|
||||||
|
self.env, statement=shared_statement, amount=amount,
|
||||||
|
partner=partner)
|
||||||
|
pairs.append((bank_line, recv_lines))
|
||||||
|
|
||||||
|
orphan_line = f.make_bank_line(
|
||||||
|
self.env, statement=shared_statement, amount=999.99, partner=partner)
|
||||||
|
|
||||||
|
all_lines = self.env['account.bank.statement.line'].browse(
|
||||||
|
[p[0].id for p in pairs] + [orphan_line.id])
|
||||||
|
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_batch(
|
||||||
|
all_lines, strategy='auto')
|
||||||
|
|
||||||
|
self.assertEqual(result['reconciled_count'], 3)
|
||||||
|
self.assertGreaterEqual(result['skipped'], 1)
|
||||||
|
self.assertEqual(len(result['errors']), 0)
|
||||||
|
|
||||||
|
def test_batch_handles_empty_recordset(self):
|
||||||
|
empty = self.env['account.bank.statement.line']
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_batch(empty)
|
||||||
|
self.assertEqual(result['reconciled_count'], 0)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'integration')
|
||||||
|
class TestSuggestThenAccept(TransactionCase):
|
||||||
|
"""Full flow: suggest_matches creates suggestions; accept_suggestion reconciles."""
|
||||||
|
|
||||||
|
def test_suggest_then_accept(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'})
|
||||||
|
invoice = f.make_invoice(self.env, partner=partner, amount=750.00)
|
||||||
|
bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner,
|
||||||
|
memo='Test suggest accept')
|
||||||
|
|
||||||
|
suggestions = self.env['fusion.reconcile.engine'].suggest_matches(
|
||||||
|
bank_line, limit_per_line=3)
|
||||||
|
|
||||||
|
self.assertIn(bank_line.id, suggestions)
|
||||||
|
self.assertGreater(len(suggestions[bank_line.id]), 0,
|
||||||
|
"Engine should suggest at least one candidate for matching invoice")
|
||||||
|
|
||||||
|
top_suggestion_id = suggestions[bank_line.id][0]['id']
|
||||||
|
sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id)
|
||||||
|
result = self.env['fusion.reconcile.engine'].accept_suggestion(sug)
|
||||||
|
|
||||||
|
self.assertGreater(len(result['partial_ids']), 0)
|
||||||
|
sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by'])
|
||||||
|
self.assertEqual(sug.state, 'accepted')
|
||||||
|
self.assertTrue(sug.accepted_at)
|
||||||
|
bank_line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertTrue(bank_line.is_reconciled)
|
||||||
|
|
||||||
|
def test_suggest_supersedes_prior_pending(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Supersede Test'})
|
||||||
|
bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner)
|
||||||
|
invoice = f.make_invoice(self.env, partner=partner, amount=100.00)
|
||||||
|
|
||||||
|
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
|
||||||
|
first_pending = self.env['fusion.reconcile.suggestion'].search([
|
||||||
|
('statement_line_id', '=', bank_line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
])
|
||||||
|
self.assertGreater(len(first_pending), 0)
|
||||||
|
|
||||||
|
self.env['fusion.reconcile.engine'].suggest_matches(bank_line)
|
||||||
|
first_pending.invalidate_recordset(['state'])
|
||||||
|
for s in first_pending:
|
||||||
|
self.assertEqual(s.state, 'superseded')
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'integration')
|
||||||
|
class TestUnreconcile(TransactionCase):
|
||||||
|
"""Reverse a reconciliation."""
|
||||||
|
|
||||||
|
def test_unreconcile_removes_partial(self):
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00)
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
partials = self.env['account.partial.reconcile'].browse(result['partial_ids'])
|
||||||
|
self.assertGreater(len(partials), 0)
|
||||||
|
|
||||||
|
unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials)
|
||||||
|
|
||||||
|
self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0)
|
||||||
|
self.assertFalse(partials.exists())
|
||||||
|
bank_line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertFalse(bank_line.is_reconciled)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'integration')
|
||||||
|
class TestEngineEdgeCases(TransactionCase):
|
||||||
|
"""Edge cases that came up during engine implementation."""
|
||||||
|
|
||||||
|
def test_reconcile_validates_line_exists(self):
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
self.env['account.bank.statement.line'],
|
||||||
|
against_lines=self.env['account.move.line'])
|
||||||
|
|
||||||
|
def test_already_reconciled_line_skipped_in_batch(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Already Reconciled'})
|
||||||
|
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||||
|
self.env, amount=50.00, partner=partner)
|
||||||
|
|
||||||
|
self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=recv_lines)
|
||||||
|
bank_line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertTrue(bank_line.is_reconciled)
|
||||||
|
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line)
|
||||||
|
self.assertGreater(result['skipped'], 0)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Property-based tests for reconcile engine invariants.
|
||||||
|
|
||||||
|
Hypothesis generates random input combinations to catch edge cases that
|
||||||
|
example-based TDD missed. Each test runs N times (default 50 -- bumpable
|
||||||
|
via @settings)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from hypothesis import HealthCheck, given, settings, strategies as st
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import (
|
||||||
|
AmountExactStrategy,
|
||||||
|
Candidate,
|
||||||
|
FIFOStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'property_based')
|
||||||
|
class TestMatchingStrategyInvariants(TransactionCase):
|
||||||
|
"""Pure-Python invariants on the matching strategies (no ORM needed).
|
||||||
|
Faster + more iterations than DB-backed property tests."""
|
||||||
|
|
||||||
|
@given(
|
||||||
|
bank_amount=st.floats(min_value=0.01, max_value=100000.00,
|
||||||
|
allow_nan=False, allow_infinity=False),
|
||||||
|
invoice_amounts=st.lists(
|
||||||
|
st.floats(min_value=0.01, max_value=100000.00,
|
||||||
|
allow_nan=False, allow_infinity=False),
|
||||||
|
min_size=1, max_size=10,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@settings(max_examples=100, deadline=2000,
|
||||||
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||||
|
def test_amount_exact_picks_only_when_amount_matches(
|
||||||
|
self, bank_amount, invoice_amounts):
|
||||||
|
"""AmountExactStrategy returns picks IFF some candidate amount matches
|
||||||
|
bank_amount within tolerance."""
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=i, amount=round(amt, 2), partner_id=1, age_days=10)
|
||||||
|
for i, amt in enumerate(invoice_amounts)
|
||||||
|
]
|
||||||
|
bank_amount = round(bank_amount, 2)
|
||||||
|
result = AmountExactStrategy().match(
|
||||||
|
bank_amount=bank_amount, candidates=candidates)
|
||||||
|
|
||||||
|
has_match = any(
|
||||||
|
abs(c.amount - bank_amount) < 0.005 for c in candidates)
|
||||||
|
if has_match:
|
||||||
|
self.assertEqual(
|
||||||
|
len(result.picked_ids), 1,
|
||||||
|
f"bank=${bank_amount} candidates={[c.amount for c in candidates]} "
|
||||||
|
f"has_match={has_match} -> expected 1 pick, got {result.picked_ids}",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.confidence, 1.0)
|
||||||
|
else:
|
||||||
|
self.assertEqual(result.picked_ids, [])
|
||||||
|
|
||||||
|
@given(
|
||||||
|
bank_amount=st.floats(min_value=10.00, max_value=10000.00,
|
||||||
|
allow_nan=False, allow_infinity=False),
|
||||||
|
invoice_amounts=st.lists(
|
||||||
|
st.floats(min_value=1.00, max_value=10000.00,
|
||||||
|
allow_nan=False, allow_infinity=False),
|
||||||
|
min_size=1, max_size=8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@settings(max_examples=100, deadline=2000,
|
||||||
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||||
|
def test_fifo_picks_oldest_first(self, bank_amount, invoice_amounts):
|
||||||
|
"""FIFOStrategy picks candidates in order of decreasing age_days
|
||||||
|
(oldest first), stopping when remaining <= 0."""
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=i, amount=round(amt, 2), partner_id=1,
|
||||||
|
age_days=100 - i)
|
||||||
|
for i, amt in enumerate(invoice_amounts)
|
||||||
|
]
|
||||||
|
bank_amount = round(bank_amount, 2)
|
||||||
|
result = FIFOStrategy().match(
|
||||||
|
bank_amount=bank_amount, candidates=candidates)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
|
||||||
|
oldest_first_ids = [
|
||||||
|
c.id for c in sorted(candidates, key=lambda c: -c.age_days)]
|
||||||
|
self.assertEqual(
|
||||||
|
result.picked_ids,
|
||||||
|
oldest_first_ids[:len(result.picked_ids)],
|
||||||
|
)
|
||||||
|
|
||||||
|
picked_sum = sum(
|
||||||
|
c.amount for c in candidates if c.id in result.picked_ids)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
result.residual, bank_amount - picked_sum, places=2)
|
||||||
|
|
||||||
|
@given(
|
||||||
|
amounts=st.lists(
|
||||||
|
st.floats(min_value=1.00, max_value=1000.00,
|
||||||
|
allow_nan=False, allow_infinity=False),
|
||||||
|
min_size=2, max_size=6,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@settings(max_examples=50, deadline=2000,
|
||||||
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||||
|
def test_multi_invoice_finds_combination_when_one_exists(self, amounts):
|
||||||
|
"""If amounts can sum to a target via <=3 elements, MultiInvoiceStrategy
|
||||||
|
finds SOME valid combination."""
|
||||||
|
rounded = [round(a, 2) for a in amounts]
|
||||||
|
candidates = [
|
||||||
|
Candidate(id=i, amount=amt, partner_id=1, age_days=10)
|
||||||
|
for i, amt in enumerate(rounded)
|
||||||
|
]
|
||||||
|
target = round(rounded[0] + rounded[1], 2)
|
||||||
|
result = MultiInvoiceStrategy(max_combinations=3).match(
|
||||||
|
bank_amount=target, candidates=candidates)
|
||||||
|
|
||||||
|
if result.picked_ids:
|
||||||
|
picked_sum = sum(
|
||||||
|
c.amount for c in candidates if c.id in result.picked_ids)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
picked_sum, target, places=2,
|
||||||
|
msg=(f"target={target} picks={result.picked_ids} "
|
||||||
|
f"sum={picked_sum} candidates={rounded}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'property_based', 'engine_invariants')
|
||||||
|
class TestReconcileEngineInvariants(TransactionCase):
|
||||||
|
"""ORM-backed property tests against the engine.
|
||||||
|
Slower because each test creates real bank_lines + invoices."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.partner = self.env['res.partner'].create(
|
||||||
|
{'name': 'Engine Property Partner'})
|
||||||
|
self.journal = self.env['account.journal'].create({
|
||||||
|
'name': 'Engine Property Bank',
|
||||||
|
'type': 'bank',
|
||||||
|
'code': 'EPB',
|
||||||
|
})
|
||||||
|
self.receivable_account = self.env['account.account'].search([
|
||||||
|
('account_type', '=', 'asset_receivable'),
|
||||||
|
('company_ids', 'in', self.env.company.id),
|
||||||
|
], limit=1)
|
||||||
|
if not self.receivable_account:
|
||||||
|
self.skipTest("No receivable account in chart of accounts")
|
||||||
|
|
||||||
|
def _make_bank_line(self, amount):
|
||||||
|
statement = self.env['account.bank.statement'].create({
|
||||||
|
'name': f'Test stmt {amount}',
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
})
|
||||||
|
return self.env['account.bank.statement.line'].create({
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'journal_id': self.journal.id,
|
||||||
|
'date': date.today(),
|
||||||
|
'payment_ref': f'Test {amount}',
|
||||||
|
'amount': amount,
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _make_invoice(self, amount):
|
||||||
|
product = self.env['product.product'].search(
|
||||||
|
[('type', '=', 'service')], limit=1)
|
||||||
|
if not product:
|
||||||
|
product = self.env['product.product'].create({
|
||||||
|
'name': 'Property Test Service',
|
||||||
|
'type': 'service',
|
||||||
|
})
|
||||||
|
move = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'invoice_date': date.today(),
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': product.id,
|
||||||
|
'name': 'Property Test',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': amount,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
move.action_post()
|
||||||
|
return move
|
||||||
|
|
||||||
|
@given(amount=st.floats(min_value=10.00, max_value=10000.00,
|
||||||
|
allow_nan=False, allow_infinity=False))
|
||||||
|
@settings(max_examples=10, deadline=10000,
|
||||||
|
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||||
|
def test_invariant_simple_reconcile_balances(self, amount):
|
||||||
|
"""For any bank_amount = invoice_amount, reconcile_one produces:
|
||||||
|
- exactly 1 partial reconcile
|
||||||
|
- amount equal to the bank line amount
|
||||||
|
- bank line is_reconciled = True"""
|
||||||
|
amount = round(amount, 2)
|
||||||
|
bank_line = self._make_bank_line(amount)
|
||||||
|
invoice = self._make_invoice(amount)
|
||||||
|
invoice_recv_lines = invoice.line_ids.filtered(
|
||||||
|
lambda line: line.account_id.account_type == 'asset_receivable')
|
||||||
|
|
||||||
|
result = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||||
|
bank_line, against_lines=invoice_recv_lines)
|
||||||
|
|
||||||
|
self.assertGreater(
|
||||||
|
len(result['partial_ids']), 0,
|
||||||
|
f"Expected partial_ids non-empty for amount={amount}, got {result}",
|
||||||
|
)
|
||||||
|
partials = self.env['account.partial.reconcile'].browse(
|
||||||
|
result['partial_ids'])
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
sum(partials.mapped('amount')), amount, places=2)
|
||||||
|
bank_line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertTrue(
|
||||||
|
bank_line.is_reconciled,
|
||||||
|
f"is_reconciled expected True after reconcile for amount={amount}",
|
||||||
|
)
|
||||||
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""Unit tests for fusion.reconcile.engine — the 6-method public API.
|
||||||
|
|
||||||
|
Test layers:
|
||||||
|
- Layer 1: API surface (registry + method existence)
|
||||||
|
- Layer 2: unreconcile
|
||||||
|
- Layer 3: reconcile_one happy path
|
||||||
|
- Layer 4: accept_suggestion
|
||||||
|
- Layer 5: suggest_matches
|
||||||
|
- Layer 6: reconcile_batch
|
||||||
|
- Layer 7: write_off
|
||||||
|
|
||||||
|
Tests share a common setUpClass fixture providing a partner, bank
|
||||||
|
journal, statement, receivable account, and a small helper to mint a
|
||||||
|
posted customer invoice + bank statement line at given amounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineBase(TransactionCase):
|
||||||
|
"""Shared fixtures for engine tests."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.engine = cls.env['fusion.reconcile.engine']
|
||||||
|
cls.company = cls.env.company
|
||||||
|
cls.currency = cls.company.currency_id
|
||||||
|
cls.partner = cls.env['res.partner'].create({
|
||||||
|
'name': 'Engine Test Partner',
|
||||||
|
})
|
||||||
|
cls.bank_journal = cls.env['account.journal'].create({
|
||||||
|
'name': 'Engine Test Bank',
|
||||||
|
'type': 'bank',
|
||||||
|
'code': 'ETBK',
|
||||||
|
'company_id': cls.company.id,
|
||||||
|
})
|
||||||
|
cls.sales_journal = cls.env['account.journal'].search([
|
||||||
|
('type', '=', 'sale'),
|
||||||
|
('company_id', '=', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
if not cls.sales_journal:
|
||||||
|
cls.sales_journal = cls.env['account.journal'].create({
|
||||||
|
'name': 'Engine Test Sales',
|
||||||
|
'type': 'sale',
|
||||||
|
'code': 'ETSAL',
|
||||||
|
'company_id': cls.company.id,
|
||||||
|
})
|
||||||
|
cls.receivable_account = cls.env['account.account'].search([
|
||||||
|
('account_type', '=', 'asset_receivable'),
|
||||||
|
('company_ids', 'in', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
cls.income_account = cls.env['account.account'].search([
|
||||||
|
('account_type', '=', 'income'),
|
||||||
|
('company_ids', 'in', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
cls.expense_account = cls.env['account.account'].search([
|
||||||
|
('account_type', '=', 'expense'),
|
||||||
|
('company_ids', 'in', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
def _make_statement_line(self, amount, *, partner=None, ref='ENGTEST',
|
||||||
|
line_date=None):
|
||||||
|
statement = self.env['account.bank.statement'].create({
|
||||||
|
'name': 'Engine Test Statement',
|
||||||
|
'journal_id': self.bank_journal.id,
|
||||||
|
})
|
||||||
|
return self.env['account.bank.statement.line'].create({
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'journal_id': self.bank_journal.id,
|
||||||
|
'date': line_date or date.today(),
|
||||||
|
'payment_ref': ref,
|
||||||
|
'amount': amount,
|
||||||
|
'partner_id': (partner or self.partner).id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _make_invoice(self, amount, *, partner=None, inv_date=None):
|
||||||
|
"""Create + post a customer invoice for the given amount."""
|
||||||
|
inv = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': (partner or self.partner).id,
|
||||||
|
'invoice_date': inv_date or date.today(),
|
||||||
|
'journal_id': self.sales_journal.id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': 'Engine test product',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': amount,
|
||||||
|
'account_id': self.income_account.id,
|
||||||
|
'tax_ids': [(6, 0, [])],
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
inv.action_post()
|
||||||
|
return inv
|
||||||
|
|
||||||
|
def _receivable_line(self, invoice):
|
||||||
|
return invoice.line_ids.filtered(
|
||||||
|
lambda line: line.account_id.account_type == 'asset_receivable'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 1: API surface
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineApi(TestReconcileEngineBase):
|
||||||
|
"""Layer 1: the engine class exists in the registry and exposes the
|
||||||
|
six expected methods."""
|
||||||
|
|
||||||
|
def test_engine_in_registry(self):
|
||||||
|
self.assertIn('fusion.reconcile.engine', self.env.registry)
|
||||||
|
|
||||||
|
def test_engine_is_abstract_model(self):
|
||||||
|
engine = self.env['fusion.reconcile.engine']
|
||||||
|
self.assertTrue(engine._abstract)
|
||||||
|
|
||||||
|
def test_six_public_methods_callable(self):
|
||||||
|
engine = self.env['fusion.reconcile.engine']
|
||||||
|
for name in ('reconcile_one', 'reconcile_batch', 'suggest_matches',
|
||||||
|
'accept_suggestion', 'write_off', 'unreconcile'):
|
||||||
|
self.assertTrue(callable(getattr(engine, name, None)),
|
||||||
|
f"engine.{name} must be callable")
|
||||||
|
|
||||||
|
def test_reconcile_one_requires_arguments(self):
|
||||||
|
line = self._make_statement_line(100.0)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.engine.reconcile_one(line)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 2: unreconcile
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineUnreconcile(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_unreconcile_removes_partial_reconcile(self):
|
||||||
|
line = self._make_statement_line(100.0)
|
||||||
|
invoice = self._make_invoice(100.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
result = self.engine.reconcile_one(
|
||||||
|
line, against_lines=receivable)
|
||||||
|
self.assertTrue(result['partial_ids'],
|
||||||
|
"reconcile_one should produce partial_ids")
|
||||||
|
partials = self.env['account.partial.reconcile'].browse(
|
||||||
|
result['partial_ids']).exists()
|
||||||
|
self.assertTrue(partials)
|
||||||
|
|
||||||
|
out = self.engine.unreconcile(partials)
|
||||||
|
|
||||||
|
self.assertIn('unreconciled_line_ids', out)
|
||||||
|
self.assertTrue(out['unreconciled_line_ids'])
|
||||||
|
self.assertFalse(partials.exists(),
|
||||||
|
"Partials should be deleted after unreconcile")
|
||||||
|
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||||
|
self.assertFalse(receivable.reconciled)
|
||||||
|
|
||||||
|
def test_unreconcile_empty_recordset_returns_empty(self):
|
||||||
|
empty = self.env['account.partial.reconcile']
|
||||||
|
out = self.engine.unreconcile(empty)
|
||||||
|
self.assertEqual(out, {'unreconciled_line_ids': []})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 3: reconcile_one happy path
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineReconcileOne(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_reconcile_one_simple_invoice_match(self):
|
||||||
|
line = self._make_statement_line(250.0)
|
||||||
|
invoice = self._make_invoice(250.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
self.assertFalse(receivable.reconciled)
|
||||||
|
|
||||||
|
result = self.engine.reconcile_one(
|
||||||
|
line, against_lines=receivable)
|
||||||
|
|
||||||
|
self.assertIsInstance(result, dict)
|
||||||
|
self.assertIn('partial_ids', result)
|
||||||
|
self.assertIn('exchange_diff_move_id', result)
|
||||||
|
self.assertIn('write_off_move_id', result)
|
||||||
|
self.assertTrue(result['partial_ids'])
|
||||||
|
|
||||||
|
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||||
|
self.assertTrue(receivable.reconciled)
|
||||||
|
self.assertAlmostEqual(receivable.amount_residual, 0.0, places=2)
|
||||||
|
|
||||||
|
def test_reconcile_one_creates_precedent(self):
|
||||||
|
line = self._make_statement_line(125.0, ref='Engine REF#42')
|
||||||
|
invoice = self._make_invoice(125.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
before = self.env['fusion.reconcile.precedent'].search_count([
|
||||||
|
('partner_id', '=', self.partner.id),
|
||||||
|
])
|
||||||
|
self.engine.reconcile_one(line, against_lines=receivable)
|
||||||
|
after = self.env['fusion.reconcile.precedent'].search_count([
|
||||||
|
('partner_id', '=', self.partner.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(after, before + 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 4: accept_suggestion
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineAcceptSuggestion(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_accept_suggestion_reconciles_and_marks_accepted(self):
|
||||||
|
line = self._make_statement_line(310.0)
|
||||||
|
invoice = self._make_invoice(310.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||||
|
'confidence': 0.97,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'Exact amount match',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self.engine.accept_suggestion(sug)
|
||||||
|
|
||||||
|
self.assertTrue(result['partial_ids'])
|
||||||
|
self.assertEqual(sug.state, 'accepted')
|
||||||
|
self.assertTrue(sug.accepted_at)
|
||||||
|
self.assertEqual(sug.accepted_by, self.env.user)
|
||||||
|
|
||||||
|
def test_accept_suggestion_by_id(self):
|
||||||
|
line = self._make_statement_line(75.0)
|
||||||
|
invoice = self._make_invoice(75.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||||
|
'confidence': 0.91,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'OK',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
result = self.engine.accept_suggestion(sug.id)
|
||||||
|
self.assertTrue(result['partial_ids'])
|
||||||
|
self.assertEqual(sug.state, 'accepted')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 5: suggest_matches
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineSuggestMatches(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_suggest_matches_persists_pending_suggestions(self):
|
||||||
|
line = self._make_statement_line(420.0)
|
||||||
|
invoice = self._make_invoice(420.0)
|
||||||
|
# second open invoice for same partner — also a candidate
|
||||||
|
self._make_invoice(99.0)
|
||||||
|
|
||||||
|
out = self.engine.suggest_matches(line)
|
||||||
|
|
||||||
|
self.assertIn(line.id, out)
|
||||||
|
self.assertTrue(out[line.id])
|
||||||
|
suggestions = self.env['fusion.reconcile.suggestion'].search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
])
|
||||||
|
self.assertTrue(suggestions)
|
||||||
|
# Top suggestion should reference the matching invoice's receivable
|
||||||
|
top = max(suggestions, key=lambda s: s.confidence)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
self.assertIn(receivable.id, top.proposed_move_line_ids.ids)
|
||||||
|
|
||||||
|
def test_suggest_matches_supersedes_prior_pending(self):
|
||||||
|
line = self._make_statement_line(180.0)
|
||||||
|
self._make_invoice(180.0)
|
||||||
|
old_sug = self.env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'confidence': 0.5,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'prior',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.engine.suggest_matches(line)
|
||||||
|
|
||||||
|
old_sug.invalidate_recordset(['state'])
|
||||||
|
self.assertEqual(old_sug.state, 'superseded')
|
||||||
|
|
||||||
|
def test_suggest_matches_returns_empty_for_no_candidates(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||||
|
line = self._make_statement_line(10.0, partner=partner)
|
||||||
|
out = self.engine.suggest_matches(line)
|
||||||
|
self.assertEqual(out, {})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 6: reconcile_batch
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineBatch(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_reconcile_batch_auto_strategy_matches_n_lines(self):
|
||||||
|
amounts = [100.0, 200.0, 333.33]
|
||||||
|
lines = self.env['account.bank.statement.line']
|
||||||
|
for amt in amounts:
|
||||||
|
invoice = self._make_invoice(amt)
|
||||||
|
self.assertTrue(invoice)
|
||||||
|
lines |= self._make_statement_line(amt, ref=f'BATCH-{amt}')
|
||||||
|
|
||||||
|
result = self.engine.reconcile_batch(lines, strategy='auto')
|
||||||
|
|
||||||
|
self.assertEqual(result['reconciled_count'], len(amounts))
|
||||||
|
self.assertEqual(result['skipped'], 0)
|
||||||
|
self.assertEqual(result['errors'], [])
|
||||||
|
|
||||||
|
def test_reconcile_batch_skips_already_reconciled(self):
|
||||||
|
line = self._make_statement_line(50.0)
|
||||||
|
invoice = self._make_invoice(50.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
self.engine.reconcile_one(line, against_lines=receivable)
|
||||||
|
|
||||||
|
result = self.engine.reconcile_batch(line, strategy='auto')
|
||||||
|
self.assertEqual(result['reconciled_count'], 0)
|
||||||
|
self.assertEqual(result['skipped'], 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 7: write_off
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineWriteOff(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_write_off_clears_bank_line(self):
|
||||||
|
line = self._make_statement_line(40.0, ref='Bank fee')
|
||||||
|
# No invoices exist; write off the whole amount to expense.
|
||||||
|
result = self.engine.write_off(
|
||||||
|
line,
|
||||||
|
account=self.expense_account,
|
||||||
|
amount=40.0,
|
||||||
|
label='Bank fees',
|
||||||
|
)
|
||||||
|
self.assertIn('write_off_move_id', result)
|
||||||
|
line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertTrue(line.is_reconciled)
|
||||||
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
0
fusion_accounting_bank_rec/wizards/__init__.py
Normal file
@@ -1 +1,6 @@
|
|||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
def post_init_hook(env):
|
||||||
|
"""Initialize coexistence group membership based on current Enterprise install state."""
|
||||||
|
env['res.users']._fusion_recompute_coexistence_group()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Core',
|
'name': 'Fusion Accounting Core',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.0.2',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 24,
|
'sequence': 24,
|
||||||
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',
|
'summary': 'Shared base for the Fusion Accounting sub-module suite (security, shared schema, runtime helpers).',
|
||||||
@@ -30,4 +30,5 @@ Built by Nexa Systems Inc.
|
|||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
from . import ir_module_module
|
from . import ir_module_module
|
||||||
|
from . import res_users
|
||||||
from . import account_move
|
from . import account_move
|
||||||
from . import account_reconcile_model
|
from . import account_reconcile_model
|
||||||
|
from . import account_bank_statement_line
|
||||||
|
|||||||
15
fusion_accounting_core/models/account_bank_statement_line.py
Normal file
15
fusion_accounting_core/models/account_bank_statement_line.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Shared-field-ownership for account.bank.statement.line.
|
||||||
|
|
||||||
|
Enterprise's account_accountant adds cron_last_check (timestamp of the last
|
||||||
|
auto-reconcile cron run for the line). By declaring it here with the same
|
||||||
|
schema, fusion_accounting_core becomes a co-owner so the column persists
|
||||||
|
when account_accountant uninstalls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBankStatementLine(models.Model):
|
||||||
|
_inherit = "account.bank.statement.line"
|
||||||
|
|
||||||
|
cron_last_check = fields.Datetime(copy=False)
|
||||||
@@ -30,3 +30,26 @@ class IrModuleModule(models.Model):
|
|||||||
('name', '=', module_name),
|
('name', '=', module_name),
|
||||||
('state', '=', 'installed'),
|
('state', '=', 'installed'),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
def button_immediate_install(self):
|
||||||
|
"""Recompute the coexistence group after install state changes."""
|
||||||
|
result = super().button_immediate_install()
|
||||||
|
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def button_immediate_uninstall(self):
|
||||||
|
"""Recompute the coexistence group after uninstall state changes.
|
||||||
|
|
||||||
|
The MRO chains into fusion_accounting_migration's override (which runs
|
||||||
|
the safety guard before calling super); we recompute only after the
|
||||||
|
whole chain completes.
|
||||||
|
"""
|
||||||
|
result = super().button_immediate_uninstall()
|
||||||
|
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def module_uninstall(self):
|
||||||
|
"""Recompute the coexistence group after the lower-level uninstall."""
|
||||||
|
result = super().module_uninstall()
|
||||||
|
self.env['res.users']._fusion_recompute_coexistence_group()
|
||||||
|
return result
|
||||||
|
|||||||
27
fusion_accounting_core/models/res_users.py
Normal file
27
fusion_accounting_core/models/res_users.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Coexistence group membership recomputation."""
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = "res.users"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fusion_recompute_coexistence_group(self):
|
||||||
|
"""Set group membership = all internal users iff Enterprise absent.
|
||||||
|
|
||||||
|
Called from ir.module.module.button_immediate_install / uninstall
|
||||||
|
overrides. Idempotent; safe to call multiple times.
|
||||||
|
"""
|
||||||
|
group = self.env.ref(
|
||||||
|
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not group:
|
||||||
|
return
|
||||||
|
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
|
||||||
|
if enterprise_installed:
|
||||||
|
group.sudo().write({'user_ids': [(5, 0, 0)]})
|
||||||
|
else:
|
||||||
|
all_internal = self.sudo().search([('share', '=', False)])
|
||||||
|
group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
|
||||||
@@ -43,4 +43,10 @@
|
|||||||
<record id="account.group_account_manager" model="res.groups">
|
<record id="account.group_account_manager" model="res.groups">
|
||||||
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
|
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Phase 1: dynamic coexistence group -->
|
||||||
|
<record id="group_fusion_show_when_enterprise_absent" model="res.groups">
|
||||||
|
<field name="name">Fusion: Show menus when Enterprise absent</field>
|
||||||
|
<field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
|
||||||
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
BIN
fusion_accounting_core/static/description/icon.png
Normal file
BIN
fusion_accounting_core/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,2 +1,4 @@
|
|||||||
from . import test_enterprise_detection
|
from . import test_enterprise_detection
|
||||||
from . import test_shared_field_ownership
|
from . import test_shared_field_ownership
|
||||||
|
from . import test_shared_field_bank_statement
|
||||||
|
from . import test_coexistence_group
|
||||||
|
|||||||
46
fusion_accounting_core/tests/test_coexistence_group.py
Normal file
46
fusion_accounting_core/tests/test_coexistence_group.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestCoexistenceGroup(TransactionCase):
|
||||||
|
"""The 'show when Enterprise absent' group must exist and have computed membership."""
|
||||||
|
|
||||||
|
def test_group_exists(self):
|
||||||
|
group = self.env.ref(
|
||||||
|
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(group, "Coexistence group must exist")
|
||||||
|
|
||||||
|
def test_membership_matches_enterprise_state(self):
|
||||||
|
"""A user is in the group iff Enterprise accounting is NOT installed.
|
||||||
|
|
||||||
|
We can't toggle Enterprise mid-test, so just assert the current state
|
||||||
|
matches: if Enterprise is installed, group should have 0 members; if
|
||||||
|
not, the group should include all internal users.
|
||||||
|
"""
|
||||||
|
group = self.env.ref(
|
||||||
|
'fusion_accounting_core.group_fusion_show_when_enterprise_absent'
|
||||||
|
)
|
||||||
|
enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
|
||||||
|
all_internal = self.env['res.users'].sudo().search([('share', '=', False)])
|
||||||
|
if enterprise_installed:
|
||||||
|
self.assertEqual(
|
||||||
|
len(group.user_ids), 0,
|
||||||
|
"Enterprise installed -> coexistence group should be empty",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertEqual(
|
||||||
|
set(group.user_ids.ids), set(all_internal.ids),
|
||||||
|
"Enterprise absent -> coexistence group should contain all internal users",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recompute_method_exists(self):
|
||||||
|
"""The recompute helper must be callable on res.users."""
|
||||||
|
self.assertTrue(
|
||||||
|
callable(getattr(
|
||||||
|
self.env['res.users'],
|
||||||
|
'_fusion_recompute_coexistence_group',
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestSharedFieldBankStatementLine(TransactionCase):
|
||||||
|
"""Verify fusion_accounting_core declares the Enterprise extension fields
|
||||||
|
on account.bank.statement.line so they survive Enterprise uninstall."""
|
||||||
|
|
||||||
|
def test_cron_last_check_field_exists(self):
|
||||||
|
Line = self.env['account.bank.statement.line']
|
||||||
|
self.assertIn('cron_last_check', Line._fields,
|
||||||
|
"cron_last_check must be declared on account.bank.statement.line "
|
||||||
|
"(shared-field-ownership with account_accountant)")
|
||||||
|
self.assertEqual(Line._fields['cron_last_check'].type, 'datetime')
|
||||||
BIN
fusion_accounting_migration/static/description/icon.png
Normal file
BIN
fusion_accounting_migration/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -36,6 +36,7 @@
|
|||||||
<menuitem id="menu_fusion_migration_root"
|
<menuitem id="menu_fusion_migration_root"
|
||||||
name="Fusion Accounting"
|
name="Fusion Accounting"
|
||||||
sequence="95"
|
sequence="95"
|
||||||
|
web_icon="fusion_accounting_migration,static/description/icon.png"
|
||||||
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
|
groups="fusion_accounting_core.group_fusion_accounting_admin"/>
|
||||||
<menuitem id="menu_fusion_migration_wizard"
|
<menuitem id="menu_fusion_migration_wizard"
|
||||||
name="Migrate from Enterprise"
|
name="Migrate from Enterprise"
|
||||||
|
|||||||
@@ -1030,7 +1030,7 @@ class AssessmentPortal(CustomerPortal):
|
|||||||
sales_reps = []
|
sales_reps = []
|
||||||
if SalesGroup:
|
if SalesGroup:
|
||||||
sales_reps = request.env['res.users'].sudo().search([
|
sales_reps = request.env['res.users'].sudo().search([
|
||||||
('groups_id', 'in', [SalesGroup.id]),
|
('all_group_ids', 'in', [SalesGroup.id]),
|
||||||
('active', '=', True),
|
('active', '=', True),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.3.0.0',
|
'version': '19.0.5.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -102,6 +102,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'fusion_plating/static/src/scss/fusion_plating.scss',
|
'fusion_plating/static/src/scss/fusion_plating.scss',
|
||||||
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
|
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
|
||||||
|
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
|
||||||
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
|
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
|
||||||
'fusion_plating/static/src/js/recipe_tree_editor.js',
|
'fusion_plating/static/src/js/recipe_tree_editor.js',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ class ResCompany(models.Model):
|
|||||||
'Settings > Fusion Plating.',
|
'Settings > Fusion Plating.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ----- Worker auto-promotion default -----------------------------------
|
||||||
|
# Default number of successful WO completions a worker needs on a role
|
||||||
|
# before it's auto-added to their Shop Roles. Each role can override
|
||||||
|
# via fp.work.role.mastery_required.
|
||||||
|
x_fc_default_mastery_threshold = fields.Integer(
|
||||||
|
string='Default Mastery Threshold',
|
||||||
|
default=3,
|
||||||
|
help='How many successful WO completions an operator needs on a '
|
||||||
|
"task before it's added to their Shop Roles automatically. "
|
||||||
|
'New roles inherit this number; managers can override per '
|
||||||
|
'role on the role form. 1 = promote on first success; 3 = '
|
||||||
|
'solid baseline; 5+ for tasks that need real practice.',
|
||||||
|
)
|
||||||
|
|
||||||
# ----- Facility footprint for this legal entity ----------------------
|
# ----- Facility footprint for this legal entity ----------------------
|
||||||
x_fc_facility_ids = fields.One2many(
|
x_fc_facility_ids = fields.One2many(
|
||||||
'fusion.plating.facility',
|
'fusion.plating.facility',
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
readonly=False,
|
readonly=False,
|
||||||
string='Fusion Plating Timezone',
|
string='Fusion Plating Timezone',
|
||||||
)
|
)
|
||||||
|
x_fc_default_mastery_threshold = fields.Integer(
|
||||||
|
related='company_id.x_fc_default_mastery_threshold',
|
||||||
|
readonly=False,
|
||||||
|
string='Default Mastery Threshold',
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// =====================================================================
|
||||||
|
// Fusion Plating — Chatter dark-mode patch
|
||||||
|
//
|
||||||
|
// In dark mode the floating message-action toolbar (reaction / reply /
|
||||||
|
// star / link icons) renders white-on-white because Odoo sets the
|
||||||
|
// hover icon color to `white` but doesn't give the toolbar itself a
|
||||||
|
// dark background. Result: icons invisible, users can't see what
|
||||||
|
// they're hovering.
|
||||||
|
//
|
||||||
|
// Branch at compile time (Odoo 19 compiles every SCSS file into the
|
||||||
|
// `web.assets_backend` bundle with $o-webclient-color-scheme: bright,
|
||||||
|
// AND into `web.assets_web_dark` with $o-webclient-color-scheme: dark).
|
||||||
|
// Light bundle gets nothing (zero output); dark bundle gets the patch.
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
$o-webclient-color-scheme: bright !default;
|
||||||
|
|
||||||
|
@if $o-webclient-color-scheme == dark {
|
||||||
|
.o-mail-Message-actions {
|
||||||
|
// Solid dark background so light/white icons stand out
|
||||||
|
background-color: var(--o-component-bgcolor, #2b2f33) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
// Make sure every icon (reaction, reply, star, link, more) has
|
||||||
|
// enough contrast against the dark popup. Defaults sit at 35%
|
||||||
|
// opacity which barely shows.
|
||||||
|
button, .btn, .o-mail-ActionList-button {
|
||||||
|
color: rgba(255, 255, 255, 0.78) !important;
|
||||||
|
|
||||||
|
> i, > .oi, > .fa {
|
||||||
|
color: rgba(255, 255, 255, 0.82) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus, &:focus-visible, &.show {
|
||||||
|
background-color: rgba(255, 255, 255, 0.10) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
|
||||||
|
> i, > .oi, > .fa {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,20 @@
|
|||||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="Plating Certifications" name="fp_certs">
|
<!--
|
||||||
|
"Operator Training" — formerly "Plating Certifications".
|
||||||
|
Renamed to disambiguate from the customer-facing
|
||||||
|
Certificate of Conformance (fp.certificate). This tab
|
||||||
|
is the operator's process-level training record (EN,
|
||||||
|
chrome, anodize, etc.) that gates WO start.
|
||||||
|
-->
|
||||||
|
<page string="Operator Training" name="fp_certs">
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Process-level training certificates required to start
|
||||||
|
work orders. The Tablet Station blocks an operator
|
||||||
|
from hitting Start unless they hold an active
|
||||||
|
certificate for the WO's process type.
|
||||||
|
</p>
|
||||||
<field name="x_fc_certification_ids"
|
<field name="x_fc_certification_ids"
|
||||||
context="{'default_employee_id': id}">
|
context="{'default_employee_id': id}">
|
||||||
<list editable="bottom">
|
<list editable="bottom">
|
||||||
|
|||||||
@@ -27,6 +27,16 @@
|
|||||||
<field name="x_fc_default_tz"/>
|
<field name="x_fc_default_tz"/>
|
||||||
</setting>
|
</setting>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
|
<block title="Workforce Settings"
|
||||||
|
name="fp_workforce_settings"
|
||||||
|
help="Defaults that govern how the shop tracks worker skills and promotions across recipes.">
|
||||||
|
<setting id="fp_default_mastery"
|
||||||
|
string="Default Mastery Threshold"
|
||||||
|
help="How many successful WO completions an operator needs on a new task before it's added to their Shop Roles automatically. Each role can override this on its own form (e.g. masking 1, electroless nickel 5).">
|
||||||
|
<field name="x_fc_default_mastery_threshold"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
</app>
|
</app>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — MRP Bridge',
|
"name": "Fusion Plating — MRP Bridge",
|
||||||
'version': '19.0.3.0.0',
|
'version': '19.0.6.5.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -42,6 +42,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'fusion_plating_shopfloor',
|
'fusion_plating_shopfloor',
|
||||||
'fusion_plating_configurator',
|
'fusion_plating_configurator',
|
||||||
'hr',
|
'hr',
|
||||||
|
# hr_attendance gives us the standard hr.attendance model
|
||||||
|
# (check_in / check_out). fusion_clock builds on the same model
|
||||||
|
# so this works whether the shop runs vanilla attendance or the
|
||||||
|
# full Fusion Clock T&A. Bringing the dep into the bridge keeps
|
||||||
|
# the Manager Desk's "show only clocked-in workers" filter
|
||||||
|
# working out of the box.
|
||||||
|
'hr_attendance',
|
||||||
'mrp',
|
'mrp',
|
||||||
'mrp_workorder',
|
'mrp_workorder',
|
||||||
'mrp_account',
|
'mrp_account',
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ from . import account_move
|
|||||||
from . import sale_order
|
from . import sale_order
|
||||||
from . import fp_work_role
|
from . import fp_work_role
|
||||||
from . import hr_employee
|
from . import hr_employee
|
||||||
|
from . import fp_proficiency
|
||||||
from . import fp_process_node
|
from . import fp_process_node
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""Operator proficiency tracker — counts successful WO completions per
|
||||||
|
(employee, role) pair and auto-promotes the employee once the role's
|
||||||
|
mastery threshold is crossed.
|
||||||
|
|
||||||
|
The promotion mechanic lets managers casually train workers on the job:
|
||||||
|
they assign someone a task they've never done, the worker finishes it
|
||||||
|
successfully, and after N successes the role is added to the employee's
|
||||||
|
Shop Roles automatically. The operator never has to fill in a form;
|
||||||
|
their growing skill set just unlocks itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpOperatorProficiency(models.Model):
|
||||||
|
_name = 'fp.operator.proficiency'
|
||||||
|
_description = 'Fusion Plating — Operator Task Proficiency'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
_order = 'employee_id, role_id'
|
||||||
|
|
||||||
|
employee_id = fields.Many2one(
|
||||||
|
'hr.employee', string='Operator',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
role_id = fields.Many2one(
|
||||||
|
'fp.work.role', string='Role',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
completed_count = fields.Integer(
|
||||||
|
string='Completions',
|
||||||
|
default=0,
|
||||||
|
help='Number of times this operator has successfully finished a '
|
||||||
|
'WO that required this role.',
|
||||||
|
)
|
||||||
|
first_completed_at = fields.Datetime(
|
||||||
|
string='First Success',
|
||||||
|
help='When the operator finished their first WO for this role.',
|
||||||
|
)
|
||||||
|
last_completed_at = fields.Datetime(
|
||||||
|
string='Last Success',
|
||||||
|
help='Most recent WO completion against this role.',
|
||||||
|
)
|
||||||
|
promoted = fields.Boolean(
|
||||||
|
string='Promoted',
|
||||||
|
default=False,
|
||||||
|
index=True,
|
||||||
|
help='True once the role has been added to the operator\'s Shop '
|
||||||
|
'Roles automatically. Stays True even if a manager removes '
|
||||||
|
'the role afterwards — the count and promotion history are '
|
||||||
|
'preserved as a training record.',
|
||||||
|
)
|
||||||
|
promoted_at = fields.Datetime(
|
||||||
|
string='Promoted On',
|
||||||
|
help='When the auto-promotion fired (count crossed the role\'s '
|
||||||
|
'mastery threshold).',
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = fields.Char(
|
||||||
|
compute='_compute_display_name', store=True,
|
||||||
|
)
|
||||||
|
progress_label = fields.Char(
|
||||||
|
compute='_compute_progress_label',
|
||||||
|
help='"3 / 5" style indicator of how close this operator is to '
|
||||||
|
'mastery.',
|
||||||
|
)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('fp_proficiency_uniq',
|
||||||
|
'unique(employee_id, role_id)',
|
||||||
|
'There is already a proficiency record for this operator and role.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('employee_id.name', 'role_id.name')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.display_name = (
|
||||||
|
f'{rec.employee_id.name or "?"} — {rec.role_id.name or "?"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('completed_count', 'role_id.mastery_required')
|
||||||
|
def _compute_progress_label(self):
|
||||||
|
for rec in self:
|
||||||
|
target = rec.role_id.mastery_required or 0
|
||||||
|
rec.progress_label = (
|
||||||
|
f'{rec.completed_count} / {target}' if target
|
||||||
|
else str(rec.completed_count)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# API used by mrp.workorder.button_finish (via _fp_record_proficiency).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _record_completion(self, employee, role):
|
||||||
|
"""Increment the (employee, role) tally and promote if at threshold.
|
||||||
|
|
||||||
|
Idempotent for the (employee, role) pair — if no record exists,
|
||||||
|
we create one. Always uses sudo() because the worker may not
|
||||||
|
have write access to their own profile.
|
||||||
|
"""
|
||||||
|
if not employee or not role:
|
||||||
|
return self.browse()
|
||||||
|
|
||||||
|
rec = self.sudo().search([
|
||||||
|
('employee_id', '=', employee.id),
|
||||||
|
('role_id', '=', role.id),
|
||||||
|
], limit=1)
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
if rec:
|
||||||
|
new_count = rec.completed_count + 1
|
||||||
|
rec.write({
|
||||||
|
'completed_count': new_count,
|
||||||
|
'last_completed_at': now,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
rec = self.sudo().create({
|
||||||
|
'employee_id': employee.id,
|
||||||
|
'role_id': role.id,
|
||||||
|
'completed_count': 1,
|
||||||
|
'first_completed_at': now,
|
||||||
|
'last_completed_at': now,
|
||||||
|
})
|
||||||
|
rec._maybe_promote()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
def _maybe_promote(self):
|
||||||
|
"""Promote the employee if they've crossed the role's threshold.
|
||||||
|
|
||||||
|
- Already promoted: no-op (history is preserved but no duplicate
|
||||||
|
chatter spam).
|
||||||
|
- Already in Shop Roles (e.g. manager added it manually): mark
|
||||||
|
promoted but don't post chatter.
|
||||||
|
- Below threshold: nothing to do.
|
||||||
|
- At/above threshold AND not on Shop Roles yet: add the role and
|
||||||
|
post a celebratory chatter line on the employee.
|
||||||
|
"""
|
||||||
|
for rec in self:
|
||||||
|
if rec.promoted:
|
||||||
|
continue
|
||||||
|
target = rec.role_id.mastery_required or 0
|
||||||
|
if target <= 0:
|
||||||
|
continue # Auto-promotion disabled for this role
|
||||||
|
if rec.completed_count < target:
|
||||||
|
continue
|
||||||
|
employee = rec.employee_id
|
||||||
|
role = rec.role_id
|
||||||
|
already_assigned = role in employee.x_fc_work_role_ids
|
||||||
|
rec.sudo().write({
|
||||||
|
'promoted': True,
|
||||||
|
'promoted_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
if already_assigned:
|
||||||
|
# Manager pre-added the role; don't double-announce.
|
||||||
|
continue
|
||||||
|
# Add to Shop Roles + announce on the employee chatter.
|
||||||
|
employee.sudo().write({
|
||||||
|
'x_fc_work_role_ids': [(4, role.id)],
|
||||||
|
})
|
||||||
|
employee.message_post(
|
||||||
|
body=Markup(_(
|
||||||
|
'🎉 <b>%(name)s promoted</b> — qualified for '
|
||||||
|
'<b>%(role)s</b> after %(count)s successful '
|
||||||
|
'completions.'
|
||||||
|
)) % {
|
||||||
|
'name': employee.name,
|
||||||
|
'role': role.name,
|
||||||
|
'count': rec.completed_count,
|
||||||
|
},
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpWorkRole(models.Model):
|
class FpWorkRole(models.Model):
|
||||||
@@ -43,7 +43,25 @@ class FpWorkRole(models.Model):
|
|||||||
)
|
)
|
||||||
active = fields.Boolean(default=True)
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
_sql_constraints = [
|
# ------------------------------------------------------------------
|
||||||
('fp_work_role_code_uniq', 'unique(code)',
|
# Mastery threshold — how many successful WO completions a worker
|
||||||
'Role code must be unique.'),
|
# needs on this role before they're auto-promoted (added to their
|
||||||
]
|
# x_fc_work_role_ids). Default reads from the company-level Fusion
|
||||||
|
# Plating settings so a new role inherits the shop default; the
|
||||||
|
# manager can override per role for tasks that need more practice
|
||||||
|
# (e.g. masking = 1, electroless nickel plating = 5).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
mastery_required = fields.Integer(
|
||||||
|
string='Mastery Threshold',
|
||||||
|
default=lambda self: self._default_mastery_required(),
|
||||||
|
help='Number of successful WO completions a worker needs on this '
|
||||||
|
"role before they're added to its qualified-operators list "
|
||||||
|
'automatically. 1 = promote on first success; 3 = solid '
|
||||||
|
"default for everyday roles; 5+ for tasks that need real "
|
||||||
|
'practice. Defaults from Settings > Fusion Plating > '
|
||||||
|
'Default Mastery Threshold.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _default_mastery_required(self):
|
||||||
|
return self.env.company.x_fc_default_mastery_threshold or 3
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class HrEmployee(models.Model):
|
class HrEmployee(models.Model):
|
||||||
@@ -13,6 +13,12 @@ class HrEmployee(models.Model):
|
|||||||
are generated; an employee with multiple roles receives WOs for all
|
are generated; an employee with multiple roles receives WOs for all
|
||||||
of them. A small shop where the owner wears every hat just tags
|
of them. A small shop where the owner wears every hat just tags
|
||||||
themselves with every role.
|
themselves with every role.
|
||||||
|
|
||||||
|
Lead hands are a separate per-role list — they don't have to be
|
||||||
|
primary owners of those roles, but they're authorised to step in
|
||||||
|
when the regular owner is absent or behind. The Manager Desk
|
||||||
|
promotes lead hands above other workers in its dropdown for any
|
||||||
|
role they cover.
|
||||||
"""
|
"""
|
||||||
_inherit = 'hr.employee'
|
_inherit = 'hr.employee'
|
||||||
|
|
||||||
@@ -20,5 +26,136 @@ class HrEmployee(models.Model):
|
|||||||
'fp.work.role', 'fp_employee_work_role_rel',
|
'fp.work.role', 'fp_employee_work_role_rel',
|
||||||
'employee_id', 'role_id', string='Shop Roles',
|
'employee_id', 'role_id', string='Shop Roles',
|
||||||
help='Which shop roles this employee performs. Used by the '
|
help='Which shop roles this employee performs. Used by the '
|
||||||
'Manager Desk and auto-assignment on WO generation.',
|
'Manager Desk and auto-assignment on WO generation. '
|
||||||
|
'Roles are added automatically when an employee completes '
|
||||||
|
'a task that meets the role mastery threshold.',
|
||||||
)
|
)
|
||||||
|
# Per-role lead-hand list. Sarah might be a lead hand for masking +
|
||||||
|
# racking but not for plating; Mike might cover everything during
|
||||||
|
# a graveyard shift. Stored on a separate relation table so the
|
||||||
|
# primary "Shop Roles" list stays distinct from the cover-anything
|
||||||
|
# authority.
|
||||||
|
x_fc_lead_hand_role_ids = fields.Many2many(
|
||||||
|
'fp.work.role', 'fp_employee_lead_hand_role_rel',
|
||||||
|
'employee_id', 'role_id', string='Lead Hand For',
|
||||||
|
help='Roles where this employee is authorised to lead or cover '
|
||||||
|
'for an absent operator. Lead hands are surfaced first in '
|
||||||
|
'the Manager Desk worker picker for these roles.',
|
||||||
|
)
|
||||||
|
|
||||||
|
x_fc_proficiency_ids = fields.One2many(
|
||||||
|
'fp.operator.proficiency', 'employee_id',
|
||||||
|
string='Task Proficiency',
|
||||||
|
help='Per-role completion tally. Workers earn one count per WO '
|
||||||
|
'they finish on a given role. Once the count crosses the '
|
||||||
|
"role's mastery threshold the role is added to their "
|
||||||
|
'Shop Roles list automatically.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Attendance helpers — used by the Manager Desk to show who is
|
||||||
|
# currently clocked in. Works with vanilla hr_attendance or the
|
||||||
|
# full fusion_clock module — both store an open record (no
|
||||||
|
# check_out) for as long as the employee is on shift.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
x_fc_is_clocked_in = fields.Boolean(
|
||||||
|
string='Clocked In',
|
||||||
|
compute='_compute_x_fc_is_clocked_in',
|
||||||
|
search='_search_x_fc_is_clocked_in',
|
||||||
|
help='True if this employee currently has an open hr.attendance '
|
||||||
|
'record (clocked in but not clocked out).',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_x_fc_is_clocked_in(self):
|
||||||
|
"""Compute attendance status from hr.attendance.
|
||||||
|
|
||||||
|
Batched so the manager dashboard doesn't issue one query per
|
||||||
|
employee — important when the shop has dozens of operators.
|
||||||
|
"""
|
||||||
|
if not self:
|
||||||
|
return
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
for emp in self:
|
||||||
|
emp.x_fc_is_clocked_in = False
|
||||||
|
return
|
||||||
|
# One read for the whole recordset.
|
||||||
|
open_emp_ids = set(Att.sudo().search([
|
||||||
|
('employee_id', 'in', self.ids),
|
||||||
|
('check_out', '=', False),
|
||||||
|
]).mapped('employee_id').ids)
|
||||||
|
for emp in self:
|
||||||
|
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
|
||||||
|
|
||||||
|
def _search_x_fc_is_clocked_in(self, *args):
|
||||||
|
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain.
|
||||||
|
|
||||||
|
Two compounding gotchas surfaced after fusion_clock auto-closed
|
||||||
|
the demo open attendances:
|
||||||
|
|
||||||
|
1. Odoo 19 normalises ``('=', True)`` into
|
||||||
|
``('in', OrderedSet([True]))`` before invoking the search
|
||||||
|
method. The previous code only handled ``=`` / ``!=`` and
|
||||||
|
fell through to ``return []`` for ``in`` / ``not in`` —
|
||||||
|
which Odoo treats as "no constraint" and matches every
|
||||||
|
row.
|
||||||
|
|
||||||
|
2. ``('id', 'in', [])`` is also treated as no-constraint in
|
||||||
|
some Odoo versions; replaced with a ``[0]`` sentinel so
|
||||||
|
the empty-open-list case correctly matches nothing.
|
||||||
|
|
||||||
|
Strategy: reduce caller intent to a *match_set* of booleans
|
||||||
|
(which values of ``x_fc_is_clocked_in`` should match), flip on
|
||||||
|
negative operators, then translate into ``id IN`` / ``NOT IN``
|
||||||
|
on the cached open-attendance employee ids. Variable signature
|
||||||
|
future-proofs against Odoo's compute-field API shifting again.
|
||||||
|
"""
|
||||||
|
# Variable signature — Odoo 19 may pass (records, op, val).
|
||||||
|
if len(args) == 3:
|
||||||
|
_records, operator, value = args
|
||||||
|
elif len(args) == 2:
|
||||||
|
operator, value = args
|
||||||
|
else:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
if operator in ('=', '!='):
|
||||||
|
match_set = {bool(value)}
|
||||||
|
elif operator in ('in', 'not in'):
|
||||||
|
match_set = set(map(bool, value))
|
||||||
|
else:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
# Negated operators flip the match set.
|
||||||
|
if operator in ('!=', 'not in'):
|
||||||
|
match_set = {True, False} - match_set
|
||||||
|
|
||||||
|
if not match_set:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
if match_set == {True, False}:
|
||||||
|
return [] # every row matches
|
||||||
|
|
||||||
|
open_emp_ids = Att.sudo().search(
|
||||||
|
[('check_out', '=', False)]
|
||||||
|
).employee_id.ids
|
||||||
|
ids_term = open_emp_ids or [0]
|
||||||
|
return [('id', 'in' if True in match_set else 'not in', ids_term)]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fp_clocked_in_user_ids(self):
|
||||||
|
"""Return the set of res.users.ids whose linked employee is on shift.
|
||||||
|
|
||||||
|
Used by the Manager Desk controller to short-circuit the worker
|
||||||
|
dropdown to "present today" without an N+1 attendance query
|
||||||
|
per worker.
|
||||||
|
"""
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
return set()
|
||||||
|
emps = Att.sudo().search([
|
||||||
|
('check_out', '=', False),
|
||||||
|
]).mapped('employee_id')
|
||||||
|
return set(emps.user_id.ids)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
@@ -423,7 +425,7 @@ class MrpProduction(models.Model):
|
|||||||
steps_txt = wo_steps.get(wo.sequence)
|
steps_txt = wo_steps.get(wo.sequence)
|
||||||
if steps_txt:
|
if steps_txt:
|
||||||
wo.message_post(
|
wo.message_post(
|
||||||
body=_('<b>Recipe steps:</b><br/><pre>%s</pre>') % steps_txt,
|
body=Markup(_('<b>Recipe steps:</b><br/><pre>%s</pre>')) % steps_txt,
|
||||||
subtype_xmlid='mail.mt_note',
|
subtype_xmlid='mail.mt_note',
|
||||||
)
|
)
|
||||||
production.message_post(
|
production.message_post(
|
||||||
@@ -467,6 +469,40 @@ class MrpProduction(models.Model):
|
|||||||
# Auto-assign recipe BEFORE super() so work-order generation sees it
|
# Auto-assign recipe BEFORE super() so work-order generation sees it
|
||||||
self._auto_assign_recipe_from_so()
|
self._auto_assign_recipe_from_so()
|
||||||
|
|
||||||
|
# Auto-derive facility (where the job runs) so x_fc_facility_id is
|
||||||
|
# never empty downstream — it's compliance-critical (AS9100 §7.1.4
|
||||||
|
# "infrastructure"). Order: explicit value > SO override >
|
||||||
|
# company default > first active facility.
|
||||||
|
for mo in self:
|
||||||
|
if mo.x_fc_facility_id:
|
||||||
|
continue
|
||||||
|
facility = False
|
||||||
|
if mo.origin:
|
||||||
|
so = self.env['sale.order'].search(
|
||||||
|
[('name', '=', mo.origin)], limit=1,
|
||||||
|
)
|
||||||
|
if so and 'x_fc_facility_id' in so._fields:
|
||||||
|
facility = so.x_fc_facility_id
|
||||||
|
if not facility:
|
||||||
|
facility = mo.company_id.x_fc_default_facility_id
|
||||||
|
if not facility:
|
||||||
|
facility = self.env['fusion.plating.facility'].search(
|
||||||
|
[('active', '=', True)], limit=1,
|
||||||
|
)
|
||||||
|
if facility:
|
||||||
|
mo.x_fc_facility_id = facility.id
|
||||||
|
|
||||||
|
# Hard gate: MO can't be confirmed without a facility — without
|
||||||
|
# this, every downstream record (WO, batch, bath log, cert) is
|
||||||
|
# missing the "where" half of "what was made where by whom".
|
||||||
|
for mo in self:
|
||||||
|
if not mo.x_fc_facility_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot confirm MO "%s" — no plating facility set.\n\n'
|
||||||
|
'Set the facility on the MO, or configure a default '
|
||||||
|
'in Settings → Companies → Fusion Plating Defaults.'
|
||||||
|
) % (mo.name or mo.display_name))
|
||||||
|
|
||||||
res = super().action_confirm()
|
res = super().action_confirm()
|
||||||
PortalJob = self.env['fusion.plating.portal.job']
|
PortalJob = self.env['fusion.plating.portal.job']
|
||||||
for mo in self:
|
for mo in self:
|
||||||
@@ -518,7 +554,14 @@ class MrpProduction(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def button_mark_done(self):
|
def button_mark_done(self):
|
||||||
"""Override to cascade MO completion to portal job, delivery,
|
"""Override to cascade MO completion to portal job, delivery,
|
||||||
and an auto-generated draft Certificate of Conformance."""
|
and an auto-generated draft Certificate of Conformance.
|
||||||
|
|
||||||
|
Also (since the workflow is fully automated):
|
||||||
|
- Pre-fills the delivery's scheduled_date and assigned_driver
|
||||||
|
- Renders each cert's PDF immediately and links it to the
|
||||||
|
portal job + delivery so the operator doesn't have to open
|
||||||
|
the cert and click "Generate".
|
||||||
|
"""
|
||||||
res = super().button_mark_done()
|
res = super().button_mark_done()
|
||||||
Delivery = self.env.get('fusion.plating.delivery')
|
Delivery = self.env.get('fusion.plating.delivery')
|
||||||
Certificate = self.env.get('fp.certificate')
|
Certificate = self.env.get('fp.certificate')
|
||||||
@@ -538,26 +581,22 @@ class MrpProduction(models.Model):
|
|||||||
[('name', '=', mo.origin)], limit=1,
|
[('name', '=', mo.origin)], limit=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-create draft delivery record (idempotent — skip if one
|
# ----- Auto-create draft delivery (with prefills) -----------
|
||||||
# already exists for this job_ref)
|
delivery = False
|
||||||
if Delivery is not None:
|
if Delivery is not None:
|
||||||
existing_delivery = Delivery.search(
|
delivery = Delivery.search(
|
||||||
[('job_ref', '=', job.name)], limit=1,
|
[('job_ref', '=', job.name)], limit=1,
|
||||||
)
|
)
|
||||||
if not existing_delivery:
|
if not delivery:
|
||||||
Delivery.create({
|
delivery = Delivery.create(
|
||||||
'partner_id': job.partner_id.id,
|
self._fp_build_delivery_vals(mo, job),
|
||||||
'job_ref': job.name,
|
)
|
||||||
'source_facility_id': (
|
|
||||||
mo.x_fc_facility_id.id if mo.x_fc_facility_id else False
|
|
||||||
),
|
|
||||||
'state': 'draft',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Auto-create draft quality documents — which ones are created
|
# ----- Auto-create draft quality documents ------------------
|
||||||
# is driven by the customer's preferences on res.partner
|
# Which ones are created is driven by the customer's
|
||||||
# (x_fc_send_coc, x_fc_send_thickness_report). A customer that
|
# preferences on res.partner (x_fc_send_coc,
|
||||||
# never wants paperwork gets zero certs auto-generated.
|
# x_fc_send_thickness_report). A customer that never wants
|
||||||
|
# paperwork gets zero certs auto-generated.
|
||||||
if Certificate is not None:
|
if Certificate is not None:
|
||||||
customer = job.partner_id
|
customer = job.partner_id
|
||||||
want_coc = True # default for customers that predate the flag
|
want_coc = True # default for customers that predate the flag
|
||||||
@@ -586,22 +625,175 @@ class MrpProduction(models.Model):
|
|||||||
'state': 'draft',
|
'state': 'draft',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
coc_cert = False
|
||||||
if want_coc:
|
if want_coc:
|
||||||
existing = Certificate.search(
|
coc_cert = Certificate.search(
|
||||||
[('production_id', '=', mo.id),
|
[('production_id', '=', mo.id),
|
||||||
('certificate_type', '=', 'coc')], limit=1,
|
('certificate_type', '=', 'coc')], limit=1,
|
||||||
)
|
)
|
||||||
if not existing:
|
if not coc_cert:
|
||||||
Certificate.create({**base_vals, 'certificate_type': 'coc'})
|
coc_cert = Certificate.create({**base_vals, 'certificate_type': 'coc'})
|
||||||
|
|
||||||
if want_thickness:
|
# Pull in any thickness readings the inspector logged
|
||||||
existing = Certificate.search(
|
# against this MO so they show up on the CoC PDF.
|
||||||
|
# Aerospace/Nadcap customers require these — without them
|
||||||
|
# the cert is just a piece of paper.
|
||||||
|
ThicknessReading = self.env.get('fp.thickness.reading')
|
||||||
|
if coc_cert and ThicknessReading is not None:
|
||||||
|
orphan_readings = ThicknessReading.search([
|
||||||
|
('production_id', '=', mo.id),
|
||||||
|
('certificate_id', '=', False),
|
||||||
|
])
|
||||||
|
if orphan_readings:
|
||||||
|
orphan_readings.write({'certificate_id': coc_cert.id})
|
||||||
|
|
||||||
|
# Skip thickness cert when CoC also wanted — the CoC
|
||||||
|
# template already embeds thickness readings, so creating
|
||||||
|
# a separate thickness cert just produces a duplicate PDF.
|
||||||
|
# Only create a standalone thickness cert when the customer
|
||||||
|
# has explicitly opted OUT of CoC and only wants thickness.
|
||||||
|
thickness_cert = False
|
||||||
|
if want_thickness and not want_coc:
|
||||||
|
thickness_cert = Certificate.search(
|
||||||
[('production_id', '=', mo.id),
|
[('production_id', '=', mo.id),
|
||||||
('certificate_type', '=', 'thickness_report')], limit=1,
|
('certificate_type', '=', 'thickness_report')], limit=1,
|
||||||
)
|
)
|
||||||
if not existing:
|
if not thickness_cert:
|
||||||
Certificate.create({
|
thickness_cert = Certificate.create({
|
||||||
**base_vals,
|
**base_vals,
|
||||||
'certificate_type': 'thickness_report',
|
'certificate_type': 'thickness_report',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Issue + render PDFs and stash on the cert + portal job +
|
||||||
|
# delivery. The cert moves out of draft so chatter + DB
|
||||||
|
# state are honest. Errors never block MO completion.
|
||||||
|
for cert in (coc_cert, thickness_cert):
|
||||||
|
if not cert:
|
||||||
|
continue
|
||||||
|
if cert.state == 'draft':
|
||||||
|
try:
|
||||||
|
cert.action_issue()
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
'Cert auto-issue failed for %s', cert.name,
|
||||||
|
)
|
||||||
|
if not cert.attachment_id:
|
||||||
|
try:
|
||||||
|
self._fp_generate_cert_pdf(cert, job, delivery)
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
'Cert PDF auto-render failed for %s', cert.name,
|
||||||
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# #5 — Delivery auto-prefill helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _fp_build_delivery_vals(self, mo, job):
|
||||||
|
"""Build the create-vals for the auto-generated draft delivery.
|
||||||
|
|
||||||
|
Sets scheduled_date and assigned_driver_id so the dispatcher
|
||||||
|
doesn't have to fill them in for every job. tracking_ref stays
|
||||||
|
empty — it's the carrier's number, the operator pastes it once
|
||||||
|
the carrier accepts the package.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
# Prefer the portal job's target ship date; otherwise schedule
|
||||||
|
# for two business days out as a sane default.
|
||||||
|
scheduled = (
|
||||||
|
fields.Datetime.to_datetime(job.target_ship_date)
|
||||||
|
if getattr(job, 'target_ship_date', False)
|
||||||
|
else fields.Datetime.now() + timedelta(days=2)
|
||||||
|
)
|
||||||
|
# Auto-pick a driver: clocked-in operators tagged is_driver,
|
||||||
|
# falling back to any active driver if the shift is empty so
|
||||||
|
# the field doesn't stay blank.
|
||||||
|
Emp = self.env['hr.employee']
|
||||||
|
driver = Emp.search([
|
||||||
|
('x_fc_is_driver', '=', True),
|
||||||
|
('x_fc_is_clocked_in', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
], order='id', limit=1)
|
||||||
|
if not driver:
|
||||||
|
driver = Emp.search([
|
||||||
|
('x_fc_is_driver', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
], order='id', limit=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_id': mo.company_id.id or self.env.company.id,
|
||||||
|
'partner_id': job.partner_id.id,
|
||||||
|
'job_ref': job.name,
|
||||||
|
'source_facility_id': (
|
||||||
|
mo.x_fc_facility_id.id if mo.x_fc_facility_id else False
|
||||||
|
),
|
||||||
|
'scheduled_date': scheduled,
|
||||||
|
'assigned_driver_id': driver.id if driver else False,
|
||||||
|
'state': 'draft',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# #3 — Render the cert PDF + cross-link it everywhere it's needed
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _fp_generate_cert_pdf(self, cert, job, delivery):
|
||||||
|
"""Render a fp.certificate to PDF and attach it to the cert,
|
||||||
|
the portal job, and the delivery (so the customer-facing portal
|
||||||
|
and the shipping email both find it without an extra step).
|
||||||
|
|
||||||
|
Uses the rich fp.certificate-bound report (action_report_coc_en
|
||||||
|
or action_report_coc_fr based on partner lang). The older
|
||||||
|
action_report_coc is portal-job bound and produces a bare header
|
||||||
|
— don't use it here.
|
||||||
|
"""
|
||||||
|
# Pick the report variant by the customer's preferred language.
|
||||||
|
lang = (cert.partner_id.lang or '').lower() if cert.partner_id else ''
|
||||||
|
is_fr = lang.startswith('fr')
|
||||||
|
report = self.env.ref(
|
||||||
|
'fusion_plating_reports.action_report_coc_fr'
|
||||||
|
if is_fr
|
||||||
|
else 'fusion_plating_reports.action_report_coc_en',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not report:
|
||||||
|
# Last-resort fallback to the EN variant if FR is missing.
|
||||||
|
report = self.env.ref(
|
||||||
|
'fusion_plating_reports.action_report_coc_en',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not report:
|
||||||
|
return # reports module not available
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
|
||||||
|
pdf_content, _ext = report.with_context(
|
||||||
|
force_report_rendering=True,
|
||||||
|
)._render_qweb_pdf(report.report_name, [cert.id])
|
||||||
|
|
||||||
|
# Filename: CoC-<CustomerSlug>-<CertName>.pdf so the email
|
||||||
|
# attachment doesn't just say CERT-00123.pdf to the customer.
|
||||||
|
cust_name = cert.partner_id.name if cert.partner_id else ''
|
||||||
|
cust_slug = re.sub(r'[^A-Za-z0-9]+', '_', cust_name).strip('_') or 'Customer'
|
||||||
|
prefix = 'CoC' if cert.certificate_type == 'coc' else 'Thickness'
|
||||||
|
filename = f'{prefix}-{cust_slug}-{cert.name}.pdf'
|
||||||
|
|
||||||
|
att = self.env['ir.attachment'].create({
|
||||||
|
'name': filename,
|
||||||
|
'type': 'binary',
|
||||||
|
'datas': base64.b64encode(pdf_content),
|
||||||
|
'res_model': 'fp.certificate',
|
||||||
|
'res_id': cert.id,
|
||||||
|
'mimetype': 'application/pdf',
|
||||||
|
})
|
||||||
|
cert.attachment_id = att.id
|
||||||
|
|
||||||
|
# Cross-link CoC to portal job + delivery; thickness report just
|
||||||
|
# lives on the cert (operator can attach it manually if they
|
||||||
|
# ever need it on the delivery).
|
||||||
|
if cert.certificate_type == 'coc':
|
||||||
|
if job and not job.coc_attachment_id:
|
||||||
|
job.coc_attachment_id = att.id
|
||||||
|
if delivery and not delivery.coc_attachment_id:
|
||||||
|
delivery.coc_attachment_id = att.id
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class MrpWorkorder(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Plating-specific fields
|
# Plating-specific fields
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
x_fc_requires_bath = fields.Boolean(
|
||||||
|
string='Requires Bath/Tank',
|
||||||
|
compute='_compute_requires_bath',
|
||||||
|
store=False,
|
||||||
|
help='True when this WO involves a chemistry bath. Surfaced to '
|
||||||
|
'the form view so bath/tank fields render as required.',
|
||||||
|
)
|
||||||
x_fc_bath_id = fields.Many2one(
|
x_fc_bath_id = fields.Many2one(
|
||||||
'fusion.plating.bath', string='Bath', tracking=True,
|
'fusion.plating.bath', string='Bath', tracking=True,
|
||||||
)
|
)
|
||||||
@@ -44,10 +51,24 @@ class MrpWorkorder(models.Model):
|
|||||||
string='Thickness Unit', default='mils',
|
string='Thickness Unit', default='mils',
|
||||||
)
|
)
|
||||||
x_fc_dwell_time_minutes = fields.Float(string='Dwell Time (min)')
|
x_fc_dwell_time_minutes = fields.Float(string='Dwell Time (min)')
|
||||||
|
# Falls back to the MO's facility when the workcenter has none —
|
||||||
|
# most stub workcenters auto-created from process node names don't
|
||||||
|
# have facility_id, but the MO always does (enforced at confirm).
|
||||||
x_fc_facility_id = fields.Many2one(
|
x_fc_facility_id = fields.Many2one(
|
||||||
'fusion.plating.facility', string='Facility',
|
'fusion.plating.facility', string='Facility',
|
||||||
related='workcenter_id.x_fc_facility_id', store=True, readonly=True,
|
compute='_compute_facility_id', store=True, readonly=False,
|
||||||
|
help='Plating facility where this WO runs. Falls back to the '
|
||||||
|
'MO\'s facility when the workcenter has none.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends('workcenter_id.x_fc_facility_id', 'production_id.x_fc_facility_id')
|
||||||
|
def _compute_facility_id(self):
|
||||||
|
for wo in self:
|
||||||
|
wo.x_fc_facility_id = (
|
||||||
|
wo.workcenter_id.x_fc_facility_id
|
||||||
|
or wo.production_id.x_fc_facility_id
|
||||||
|
or wo.x_fc_facility_id
|
||||||
|
)
|
||||||
x_fc_workcenter_cost_hour = fields.Float(
|
x_fc_workcenter_cost_hour = fields.Float(
|
||||||
string='Station Rate ($/hr)',
|
string='Station Rate ($/hr)',
|
||||||
related='workcenter_id.costs_hour', readonly=True,
|
related='workcenter_id.costs_hour', readonly=True,
|
||||||
@@ -70,6 +91,34 @@ class MrpWorkorder(models.Model):
|
|||||||
'recipe operation on WO generation).',
|
'recipe operation on WO generation).',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Timer audit — surface the who / when of the timer on the WO header.
|
||||||
|
# Odoo records every start/stop in mrp.workcenter.productivity but
|
||||||
|
# the operator + manager need to see "started by Sarah at 09:14,
|
||||||
|
# finished by Sarah at 11:42" without drilling into time_ids.
|
||||||
|
# Populated by the button_start / button_finish overrides below.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
x_fc_started_by_user_id = fields.Many2one(
|
||||||
|
'res.users', string='Started By',
|
||||||
|
readonly=True, copy=False,
|
||||||
|
help='The operator who first hit Start on this work order.',
|
||||||
|
)
|
||||||
|
x_fc_started_at = fields.Datetime(
|
||||||
|
string='Started At',
|
||||||
|
readonly=True, copy=False,
|
||||||
|
help='Wall-clock time the timer first started running.',
|
||||||
|
)
|
||||||
|
x_fc_finished_by_user_id = fields.Many2one(
|
||||||
|
'res.users', string='Finished By',
|
||||||
|
readonly=True, copy=False,
|
||||||
|
help='The operator who hit Finish to close the WO.',
|
||||||
|
)
|
||||||
|
x_fc_finished_at = fields.Datetime(
|
||||||
|
string='Finished At',
|
||||||
|
readonly=True, copy=False,
|
||||||
|
help='Wall-clock time the timer was closed for the last time.',
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Workflow step tracking
|
# Workflow step tracking
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -421,13 +470,160 @@ class MrpWorkorder(models.Model):
|
|||||||
return {'holds': holds, 'ncrs': ncrs}
|
return {'holds': holds, 'ncrs': ncrs}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# T2.2 — Certification gate on WO start
|
# write() — fire an in-Odoo notification when a worker is assigned.
|
||||||
|
# Email is intentionally NOT sent here; the operator gets a bell-icon
|
||||||
|
# ping in Odoo Discuss the moment the manager picks them. The
|
||||||
|
# fp.notification.template hooks still send emails for customer-facing
|
||||||
|
# events, but worker assignment is internal.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
def write(self, vals):
|
||||||
|
# Snapshot the previous assignee so we know if it actually changed.
|
||||||
|
# We only notify on a real change to a non-empty value (clearing
|
||||||
|
# the field doesn't deserve a ping).
|
||||||
|
previous = {wo.id: wo.x_fc_assigned_user_id.id for wo in self}
|
||||||
|
res = super().write(vals)
|
||||||
|
if 'x_fc_assigned_user_id' in vals:
|
||||||
|
for wo in self:
|
||||||
|
new_id = wo.x_fc_assigned_user_id.id
|
||||||
|
if new_id and new_id != previous.get(wo.id):
|
||||||
|
wo._fp_notify_assignee()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _fp_notify_assignee(self):
|
||||||
|
"""Send a bell-icon notification to the newly-assigned operator.
|
||||||
|
|
||||||
|
Uses message_type='user_notification' which routes to the user's
|
||||||
|
Inbox in Discuss without creating a chatter entry on the record
|
||||||
|
(Odoo treats it as a transient ping). The body is intentionally
|
||||||
|
terse — operators read these on a tablet between jobs.
|
||||||
|
"""
|
||||||
|
for wo in self:
|
||||||
|
user = wo.x_fc_assigned_user_id
|
||||||
|
if not user or not user.partner_id:
|
||||||
|
continue
|
||||||
|
mo = wo.production_id
|
||||||
|
customer = wo.x_fc_customer_id.name if wo.x_fc_customer_id else ''
|
||||||
|
product = (
|
||||||
|
mo.product_id.display_name if mo and mo.product_id else ''
|
||||||
|
)
|
||||||
|
qty = int(mo.product_qty or 0) if mo else 0
|
||||||
|
wc = wo.workcenter_id.name or ''
|
||||||
|
role = wo.x_fc_work_role_id.name or ''
|
||||||
|
|
||||||
|
# Build a short, scannable body
|
||||||
|
lines = [
|
||||||
|
_('You have been assigned <b>%s</b>.', wo.display_name or wo.name),
|
||||||
|
_('MO: %s · %s · Qty %s', mo.name if mo else '—', product, qty),
|
||||||
|
]
|
||||||
|
if wc:
|
||||||
|
lines.append(_('Work centre: %s', wc))
|
||||||
|
if role:
|
||||||
|
lines.append(_('Role: %s', role))
|
||||||
|
if customer:
|
||||||
|
lines.append(_('Customer: %s', customer))
|
||||||
|
body = '<br/>'.join(lines)
|
||||||
|
|
||||||
|
wo.message_notify(
|
||||||
|
partner_ids=user.partner_id.ids,
|
||||||
|
subject=_('Work order assigned — %s', wo.display_name or wo.name),
|
||||||
|
body=body,
|
||||||
|
# Inbox-only ping; no chatter post, no email.
|
||||||
|
email_layout_xmlid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# T2.2 — Certification gate on WO start
|
||||||
|
# T2.3 — Required-field gate (bath/tank for wet WOs, assigned operator)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
WET_FAMILIES = (
|
||||||
|
'plating', 'pre_treatment', 'post_treatment',
|
||||||
|
'strip', 'passivation',
|
||||||
|
)
|
||||||
|
# Keyword fallback used when the workcenter / process-type metadata
|
||||||
|
# is missing — covers most shop floor naming conventions. Lowercased.
|
||||||
|
WET_NAME_KEYWORDS = (
|
||||||
|
'plat', 'nickel', 'chrome', 'anodiz', 'zinc',
|
||||||
|
'etch', 'clean', 'rinse', 'strip', 'passivat',
|
||||||
|
'zincate', 'alkalin', 'acid', 'electroless',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('x_fc_bath_id', 'name', 'workcenter_id')
|
||||||
|
def _compute_requires_bath(self):
|
||||||
|
for wo in self:
|
||||||
|
wo.x_fc_requires_bath = wo._fp_is_wet_process()
|
||||||
|
|
||||||
|
def _fp_is_wet_process(self):
|
||||||
|
"""Best-effort check: does this WO involve a chemistry bath?
|
||||||
|
|
||||||
|
Three signals, in priority order:
|
||||||
|
1. A bath is already linked → definitely wet
|
||||||
|
2. The workcenter's FP work-centre supports a wet process family
|
||||||
|
3. The WO's name contains a wet-process keyword
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.x_fc_bath_id:
|
||||||
|
return True
|
||||||
|
wc = self.workcenter_id
|
||||||
|
fpwc = getattr(wc, 'x_fc_fp_work_center_id', False)
|
||||||
|
if fpwc:
|
||||||
|
families = set(fpwc.supported_process_ids.mapped('process_family'))
|
||||||
|
if families & set(self.WET_FAMILIES):
|
||||||
|
return True
|
||||||
|
name = (self.name or '').lower()
|
||||||
|
return any(k in name for k in self.WET_NAME_KEYWORDS)
|
||||||
|
|
||||||
|
def _fp_check_required_fields_before_start(self):
|
||||||
|
"""Block button_start if the WO is missing data the shop must
|
||||||
|
record for traceability + compliance.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
• Every WO needs an assigned operator (x_fc_assigned_user_id) —
|
||||||
|
without it, productivity records can't be attributed and
|
||||||
|
proficiency tracking goes nowhere.
|
||||||
|
• Wet (bath) WOs additionally need x_fc_bath_id + x_fc_tank_id —
|
||||||
|
for chemistry traceability and physical-location audit
|
||||||
|
(which exact tank ran the job).
|
||||||
|
"""
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
for wo in self:
|
||||||
|
missing = []
|
||||||
|
if not wo.x_fc_assigned_user_id:
|
||||||
|
missing.append(_('Assigned Operator'))
|
||||||
|
if wo._fp_is_wet_process():
|
||||||
|
if not wo.x_fc_bath_id:
|
||||||
|
missing.append(_('Bath'))
|
||||||
|
if not wo.x_fc_tank_id:
|
||||||
|
missing.append(_('Tank'))
|
||||||
|
if missing:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot start work order "%(wo)s" — please fill these '
|
||||||
|
'required fields first:\n • %(fields)s\n\n'
|
||||||
|
'Open the work order form and have the planner set them.'
|
||||||
|
) % {
|
||||||
|
'wo': wo.display_name or wo.name,
|
||||||
|
'fields': '\n • '.join(missing),
|
||||||
|
})
|
||||||
|
|
||||||
def button_start(self):
|
def button_start(self):
|
||||||
"""Block start unless the current user's linked employee holds
|
"""Block start unless the current user's linked employee holds
|
||||||
an active certification for this WO's process type."""
|
an active certification for this WO's process type AND every
|
||||||
|
required field for traceability is filled in."""
|
||||||
|
self._fp_check_required_fields_before_start()
|
||||||
self._fp_check_operator_certification()
|
self._fp_check_operator_certification()
|
||||||
return super().button_start()
|
res = super().button_start()
|
||||||
|
# Capture audit AFTER the super call so we don't stamp WOs that
|
||||||
|
# the cert gate (or any other downstream check) rejected.
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
uid = self.env.user.id
|
||||||
|
for wo in self:
|
||||||
|
# Only stamp the first time — subsequent pause/resume cycles
|
||||||
|
# shouldn't overwrite the original start.
|
||||||
|
if not wo.x_fc_started_at:
|
||||||
|
wo.sudo().write({
|
||||||
|
'x_fc_started_at': now,
|
||||||
|
'x_fc_started_by_user_id': uid,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
|
||||||
def _fp_check_operator_certification(self):
|
def _fp_check_operator_certification(self):
|
||||||
"""Raise UserError if the user isn't certified for this process."""
|
"""Raise UserError if the user isn't certified for this process."""
|
||||||
@@ -461,14 +657,57 @@ class MrpWorkorder(models.Model):
|
|||||||
# T1.3 — Rack MTO increment when a rack was used
|
# T1.3 — Rack MTO increment when a rack was used
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def button_finish(self):
|
def button_finish(self):
|
||||||
"""Finish the WO, bump rack MTO, spawn bake window if required."""
|
"""Finish the WO, bump rack MTO, spawn bake window if required.
|
||||||
|
|
||||||
|
Also stamps the finished_by/finished_at audit fields and runs
|
||||||
|
the proficiency tracker so workers earn credit toward auto-
|
||||||
|
promotion (see fp.operator.proficiency).
|
||||||
|
"""
|
||||||
res = super().button_finish()
|
res = super().button_finish()
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
uid = self.env.user.id
|
||||||
for wo in self:
|
for wo in self:
|
||||||
if wo.x_fc_rack_id:
|
if wo.x_fc_rack_id:
|
||||||
wo.x_fc_rack_id._increment_mto(1.0)
|
wo.x_fc_rack_id._increment_mto(1.0)
|
||||||
|
# Audit stamp — overwrite each time the WO is closed so the
|
||||||
|
# most recent finish is what's shown.
|
||||||
|
wo.sudo().write({
|
||||||
|
'x_fc_finished_at': now,
|
||||||
|
'x_fc_finished_by_user_id': uid,
|
||||||
|
})
|
||||||
|
# Proficiency tracking + auto-promotion. Wrapped in try so a
|
||||||
|
# tracker glitch never blocks production.
|
||||||
|
try:
|
||||||
|
wo._fp_record_proficiency()
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
'Proficiency tracker failed for WO %s', wo.id,
|
||||||
|
)
|
||||||
self._fp_spawn_bake_window_if_needed()
|
self._fp_spawn_bake_window_if_needed()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def _fp_record_proficiency(self):
|
||||||
|
"""Increment the (employee, role) completion counter and promote
|
||||||
|
the employee if they've crossed the role's mastery threshold.
|
||||||
|
|
||||||
|
Runs on the assigned worker, NOT the user who clicked Finish —
|
||||||
|
sometimes a manager finishes a job on behalf of an absent
|
||||||
|
operator. The CREDIT belongs to the assigned worker.
|
||||||
|
"""
|
||||||
|
Prof = self.env.get('fp.operator.proficiency')
|
||||||
|
if Prof is None:
|
||||||
|
return # tracker model not installed yet — nothing to do
|
||||||
|
for wo in self:
|
||||||
|
user = wo.x_fc_assigned_user_id
|
||||||
|
role = wo.x_fc_work_role_id
|
||||||
|
if not user or not role:
|
||||||
|
continue
|
||||||
|
employee = user.employee_id
|
||||||
|
if not employee:
|
||||||
|
continue
|
||||||
|
Prof.sudo()._record_completion(employee, role)
|
||||||
|
|
||||||
def _fp_spawn_bake_window_if_needed(self):
|
def _fp_spawn_bake_window_if_needed(self):
|
||||||
"""Create a fusion.plating.bake.window record if the MO's coating
|
"""Create a fusion.plating.bake.window record if the MO's coating
|
||||||
config requires it and this WO was the plating step.
|
config requires it and this WO was the plating step.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +70,89 @@ class SaleOrder(models.Model):
|
|||||||
tracking=True,
|
tracking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SO confirm → auto-create a draft MO so the manager has something
|
||||||
|
# to assign. The configurator emits a service-product line, which
|
||||||
|
# bypasses Odoo's native MO routing — without this hook the workflow
|
||||||
|
# stage stalls at 'assign_work' because action_fp_assign_to_me
|
||||||
|
# searches for DRAFT MOs that don't exist.
|
||||||
|
#
|
||||||
|
# Idempotent — never creates a second MO for the same SO.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def action_confirm(self):
|
||||||
|
res = super().action_confirm()
|
||||||
|
for so in self:
|
||||||
|
try:
|
||||||
|
so._fp_auto_create_mo()
|
||||||
|
except Exception as exc:
|
||||||
|
# Don't block SO confirm — log + continue. The manager
|
||||||
|
# can still create the MO manually.
|
||||||
|
so.message_post(
|
||||||
|
body=Markup(_('Auto-MO creation failed: <code>%s</code>. '
|
||||||
|
'Create the MO manually from MRP.')) % exc,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _fp_auto_create_mo(self):
|
||||||
|
"""Create one draft MO per SO that doesn't already have one.
|
||||||
|
|
||||||
|
Resolution order for the manufactured product:
|
||||||
|
1. The configurator's part catalog → linked product (if any).
|
||||||
|
2. The configurator's coating config → linked product (if any).
|
||||||
|
3. The shop's fallback FP-WIDGET (used for service-line orders).
|
||||||
|
|
||||||
|
Resolution for the recipe:
|
||||||
|
1. configurator.coating_config_id.recipe_id (if the field exists)
|
||||||
|
2. configurator.part_catalog_id.recipe_id (if the field exists)
|
||||||
|
3. The first installed fp.process.node of node_type='recipe'.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
Production = self.env['mrp.production']
|
||||||
|
existing = Production.search_count([('origin', '=', self.name)])
|
||||||
|
if existing:
|
||||||
|
return # idempotent
|
||||||
|
|
||||||
|
cfg = self.x_fc_configurator_id if 'x_fc_configurator_id' in self._fields else False
|
||||||
|
product = False
|
||||||
|
recipe = False
|
||||||
|
if cfg:
|
||||||
|
if cfg.part_catalog_id and 'product_id' in cfg.part_catalog_id._fields:
|
||||||
|
product = cfg.part_catalog_id.product_id
|
||||||
|
if not recipe and cfg.coating_config_id and 'recipe_id' in cfg.coating_config_id._fields:
|
||||||
|
recipe = cfg.coating_config_id.recipe_id
|
||||||
|
if not recipe and cfg.part_catalog_id and 'recipe_id' in cfg.part_catalog_id._fields:
|
||||||
|
recipe = cfg.part_catalog_id.recipe_id
|
||||||
|
if not product:
|
||||||
|
product = self.env['product.product'].search(
|
||||||
|
[('default_code', '=', 'FP-WIDGET')], limit=1,
|
||||||
|
)
|
||||||
|
if not recipe:
|
||||||
|
recipe = self.env['fusion.plating.process.node'].search(
|
||||||
|
[('node_type', '=', 'recipe')], limit=1,
|
||||||
|
)
|
||||||
|
if not product:
|
||||||
|
self.message_post(body=_(
|
||||||
|
'Auto-MO skipped — no manufacturable product available '
|
||||||
|
'(neither part catalog nor FP-WIDGET fallback resolved).'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
qty = sum(self.order_line.mapped('product_uom_qty')) or 1
|
||||||
|
mo_vals = {
|
||||||
|
'product_id': product.id,
|
||||||
|
'product_qty': qty,
|
||||||
|
'product_uom_id': product.uom_id.id,
|
||||||
|
'origin': self.name,
|
||||||
|
}
|
||||||
|
if recipe and 'x_fc_recipe_id' in Production._fields:
|
||||||
|
mo_vals['x_fc_recipe_id'] = recipe.id
|
||||||
|
mo = Production.create(mo_vals)
|
||||||
|
self.message_post(body=Markup(_(
|
||||||
|
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
|
||||||
|
'auto-created. Accept the parts and click <b>Assign to Me</b> to '
|
||||||
|
'release it to the floor.'
|
||||||
|
)) % (mo.id, mo.name))
|
||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
'state', 'invoice_status',
|
'state', 'invoice_status',
|
||||||
'x_fc_receiving_status', 'x_fc_production_count',
|
'x_fc_receiving_status', 'x_fc_production_count',
|
||||||
@@ -99,17 +184,22 @@ class SaleOrder(models.Model):
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Paid vs invoiced
|
# Paid vs invoiced
|
||||||
if so.invoice_status == 'invoiced' and so.invoice_ids:
|
posted_invoices = so.invoice_ids.filtered(lambda i: i.state == 'posted')
|
||||||
latest = so.invoice_ids.filtered(lambda i: i.state == 'posted')
|
has_posted_invoice = bool(posted_invoices)
|
||||||
all_paid = latest and all(
|
all_paid = has_posted_invoice and all(
|
||||||
i.payment_state in ('paid', 'in_payment') for i in latest
|
i.payment_state in ('paid', 'in_payment') for i in posted_invoices
|
||||||
)
|
)
|
||||||
if shipped and all_paid:
|
if shipped and all_paid:
|
||||||
so.x_fc_workflow_stage = 'complete'
|
so.x_fc_workflow_stage = 'complete'
|
||||||
continue
|
continue
|
||||||
if all_paid and not shipped:
|
if all_paid and not shipped:
|
||||||
so.x_fc_workflow_stage = 'paid'
|
so.x_fc_workflow_stage = 'paid'
|
||||||
continue
|
continue
|
||||||
|
# Once an invoice is posted (regardless of payment), the SO has
|
||||||
|
# moved past 'shipped' — the action is on accounting, not us.
|
||||||
|
if shipped and has_posted_invoice:
|
||||||
|
so.x_fc_workflow_stage = 'invoicing'
|
||||||
|
continue
|
||||||
|
|
||||||
if shipped:
|
if shipped:
|
||||||
so.x_fc_workflow_stage = 'shipped'
|
so.x_fc_workflow_stage = 'shipped'
|
||||||
@@ -180,7 +270,7 @@ class SaleOrder(models.Model):
|
|||||||
if 'x_fc_assigned_manager_id' in mo._fields and not mo.x_fc_assigned_manager_id:
|
if 'x_fc_assigned_manager_id' in mo._fields and not mo.x_fc_assigned_manager_id:
|
||||||
mo.x_fc_assigned_manager_id = user.id
|
mo.x_fc_assigned_manager_id = user.id
|
||||||
self.message_post(
|
self.message_post(
|
||||||
body=_('Job assigned to <b>%s</b>. %d MO(s) released to the floor.')
|
body=Markup(_('Job assigned to <b>%s</b>. %d MO(s) released to the floor.'))
|
||||||
% (user.name, len(mos)),
|
% (user.name, len(mos)),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_
|
|||||||
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -39,12 +39,21 @@
|
|||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="active" widget="boolean_toggle"/>
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
<field name="mastery_required"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="description"
|
<field name="description"
|
||||||
placeholder="Short operator-facing description of what this role covers."/>
|
placeholder="Short operator-facing description of what this role covers."/>
|
||||||
</group>
|
</group>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||||
|
operator has finished this many WOs against this role, the role is
|
||||||
|
added to their Shop Roles automatically and a chatter line is
|
||||||
|
posted to their employee record. Defaults from
|
||||||
|
<em>Settings > Fusion Plating > Default Mastery Threshold</em>.
|
||||||
|
</div>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
@@ -73,24 +82,62 @@
|
|||||||
sequence="55"
|
sequence="55"
|
||||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
<!-- Employee form — add roles section -->
|
<!-- Employee form — Shop Roles + Lead Hand For + Proficiency tracker -->
|
||||||
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
|
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
|
||||||
<field name="name">hr.employee.form.fp.roles</field>
|
<field name="name">hr.employee.form.fp.roles</field>
|
||||||
<field name="model">hr.employee</field>
|
<field name="model">hr.employee</field>
|
||||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="Shop Roles" name="fp_shop_roles">
|
<page string="Shop Roles" name="fp_shop_roles"
|
||||||
|
groups="fusion_plating.group_fusion_plating_supervisor">
|
||||||
<group>
|
<group>
|
||||||
<field name="x_fc_work_role_ids" widget="many2many_tags"
|
<group string="Tasks This Operator Can Do">
|
||||||
options="{'no_create_edit': True}"
|
<field name="x_fc_work_role_ids"
|
||||||
placeholder="Tag the shop roles this employee performs..."/>
|
widget="many2many_tags"
|
||||||
<div class="text-muted" colspan="2">
|
options="{'no_create_edit': True}"
|
||||||
Work orders tagged with these roles will auto-assign to
|
placeholder="Tag the shop roles this employee performs..."/>
|
||||||
this employee (or to another employee with the same role,
|
<div class="text-muted small" colspan="2">
|
||||||
whichever is least loaded).
|
Work orders tagged with these roles auto-assign to
|
||||||
</div>
|
this employee (or to whoever has the same role and
|
||||||
|
the lighter open queue).
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
<group string="Lead Hand For"
|
||||||
|
groups="fusion_plating.group_fusion_plating_manager">
|
||||||
|
<field name="x_fc_lead_hand_role_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create_edit': True}"
|
||||||
|
placeholder="Roles where this employee can cover for absent operators..."/>
|
||||||
|
<div class="text-muted small" colspan="2">
|
||||||
|
Lead hands appear at the top of the Manager Desk
|
||||||
|
worker dropdown for these roles, even when they
|
||||||
|
aren't the primary owner. Use for cross-trained
|
||||||
|
workers who can step in during absences.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<separator string="Task Proficiency"/>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Auto-tracked: every successfully completed WO bumps the
|
||||||
|
count for its role. When the count crosses the role's
|
||||||
|
mastery threshold the role is added to <em>Tasks This
|
||||||
|
Operator Can Do</em> automatically.
|
||||||
|
</p>
|
||||||
|
<field name="x_fc_proficiency_ids" nolabel="1"
|
||||||
|
readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="role_id"/>
|
||||||
|
<field name="completed_count"/>
|
||||||
|
<field name="progress_label" string="Progress"/>
|
||||||
|
<field name="promoted" widget="boolean_toggle"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="first_completed_at"/>
|
||||||
|
<field name="last_completed_at"/>
|
||||||
|
<field name="promoted_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
</page>
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
@@ -109,17 +156,10 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Work Order form — show role + assigned worker -->
|
<!--
|
||||||
<record id="view_mrp_workorder_form_fp_roles" model="ir.ui.view">
|
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
|
||||||
<field name="name">mrp.workorder.form.fp.roles</field>
|
via mrp_workorder_views.xml (after production_id). The earlier inherit
|
||||||
<field name="model">mrp.workorder</field>
|
here would cause the fields to render twice.
|
||||||
<field name="inherit_id" ref="fusion_plating_bridge_mrp.view_mrp_workorder_form_fp_bridge"/>
|
-->
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//sheet//field[@name='x_fc_customer_id']" position="after">
|
|
||||||
<field name="x_fc_work_role_id" readonly="1"/>
|
|
||||||
<field name="x_fc_assigned_user_id"/>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -91,6 +91,12 @@
|
|||||||
<xpath expr="//sheet//field[@name='production_id']" position="after">
|
<xpath expr="//sheet//field[@name='production_id']" position="after">
|
||||||
<field name="x_fc_step_display" widget="badge" readonly="1"/>
|
<field name="x_fc_step_display" widget="badge" readonly="1"/>
|
||||||
<field name="x_fc_priority" widget="priority"/>
|
<field name="x_fc_priority" widget="priority"/>
|
||||||
|
<field name="x_fc_assigned_user_id"
|
||||||
|
string="Assigned To"
|
||||||
|
required="1"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
<field name="x_fc_work_role_id" readonly="1"/>
|
||||||
|
<field name="x_fc_requires_bath" invisible="1"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
@@ -136,6 +142,24 @@
|
|||||||
string="Expected Duration" readonly="1"/>
|
string="Expected Duration" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Audit trail surfaced from the timer overrides.
|
||||||
|
Mirrors what's already in time_ids (one row per
|
||||||
|
pause/resume) but distilled to the two events
|
||||||
|
that matter to the manager: who first picked the
|
||||||
|
job up, and who closed it out.
|
||||||
|
-->
|
||||||
|
<group string="Timer Audit" name="timer_audit">
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_started_by_user_id" readonly="1"/>
|
||||||
|
<field name="x_fc_started_at" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_finished_by_user_id" readonly="1"/>
|
||||||
|
<field name="x_fc_finished_at" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->
|
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->
|
||||||
@@ -144,8 +168,10 @@
|
|||||||
<group>
|
<group>
|
||||||
<group string="Bath & Tank">
|
<group string="Bath & Tank">
|
||||||
<field name="x_fc_facility_id"/>
|
<field name="x_fc_facility_id"/>
|
||||||
<field name="x_fc_bath_id"/>
|
<field name="x_fc_bath_id"
|
||||||
<field name="x_fc_tank_id"/>
|
required="x_fc_requires_bath"/>
|
||||||
|
<field name="x_fc_tank_id"
|
||||||
|
required="x_fc_requires_bath"/>
|
||||||
<field name="x_fc_rack_id"/>
|
<field name="x_fc_rack_id"/>
|
||||||
<field name="x_fc_rack_ref"/>
|
<field name="x_fc_rack_ref"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -92,12 +92,15 @@
|
|||||||
help="Close the open delivery record(s) and fire auto-invoice per strategy."/>
|
help="Close the open delivery record(s) and fire auto-invoice per strategy."/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- Show the workflow stage on the sheet so users always
|
<!-- Workflow stage banner — sits ABOVE the form header so it's
|
||||||
know what step they're on (readonly banner). -->
|
the first thing users see, matches the Account Hold banner.
|
||||||
<xpath expr="//sheet" position="inside">
|
Hidden for terminal states (invoicing/paid/complete/cancelled)
|
||||||
|
and the initial draft so it only shows when there's an
|
||||||
|
active in-progress step. -->
|
||||||
|
<xpath expr="//form/header" position="before">
|
||||||
<div class="alert alert-info mb-2"
|
<div class="alert alert-info mb-2"
|
||||||
style="border-radius: 6px;"
|
style="border-radius: 6px;"
|
||||||
invisible="x_fc_workflow_stage in ('draft', 'complete', 'cancelled')">
|
invisible="x_fc_workflow_stage in ('draft', 'invoicing', 'paid', 'complete', 'cancelled')">
|
||||||
<i class="fa fa-compass me-2"/>
|
<i class="fa fa-compass me-2"/>
|
||||||
<strong>Current stage:</strong>
|
<strong>Current stage:</strong>
|
||||||
<field name="x_fc_workflow_stage" readonly="1" nolabel="1" class="ms-1"/>
|
<field name="x_fc_workflow_stage" readonly="1" nolabel="1" class="ms-1"/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.3.0.0',
|
'version': '19.0.3.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -267,6 +267,16 @@ class FpCertificate(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.state != 'draft':
|
if rec.state != 'draft':
|
||||||
raise UserError(_('Only draft certificates can be issued.'))
|
raise UserError(_('Only draft certificates can be issued.'))
|
||||||
|
# Spec reference is what the cert ATTESTS — without it the
|
||||||
|
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||||
|
# naming the spec the work was performed to.
|
||||||
|
if not rec.spec_reference:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot issue certificate "%(name)s" — no Spec '
|
||||||
|
'Reference set.\n\nFill the Spec Reference field '
|
||||||
|
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||||
|
'states which standard the work meets.'
|
||||||
|
) % {'name': rec.name or rec.display_name})
|
||||||
rec.state = 'issued'
|
rec.state = 'issued'
|
||||||
rec.message_post(body=_('Certificate issued.'))
|
rec.message_post(body=_('Certificate issued.'))
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ class FpThicknessReading(models.Model):
|
|||||||
string='Product Ref', help='e.g. "2805031 / NiP/Al-alloys 2805030"',
|
string='Product Ref', help='e.g. "2805031 / NiP/Al-alloys 2805030"',
|
||||||
)
|
)
|
||||||
calibration_std_ref = fields.Char(
|
calibration_std_ref = fields.Char(
|
||||||
string='Calibration Std', help='e.g. "NiP/Al STD SET SN 100174568"',
|
string='Calibration Std',
|
||||||
|
required=True,
|
||||||
|
default='NiP/Al STD SET SN 100174568',
|
||||||
|
help='Nadcap mandatory: which calibration standard the gauge '
|
||||||
|
'was checked against. Defaults to the shop\'s primary '
|
||||||
|
'standard but should be overridden if a different std '
|
||||||
|
'was used for this reading.',
|
||||||
)
|
)
|
||||||
microscope_image_id = fields.Many2one(
|
microscope_image_id = fields.Many2one(
|
||||||
'ir.attachment', string='Microscope Image',
|
'ir.attachment', string='Microscope Image',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating - Compliance (Framework)',
|
'name': 'Fusion Plating - Compliance (Framework)',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
|
||||||
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
from odoo import api, fields, models
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpDischargeSample(models.Model):
|
class FpDischargeSample(models.Model):
|
||||||
@@ -63,4 +64,32 @@ class FpDischargeSample(models.Model):
|
|||||||
self.write({'state': 'escalated'})
|
self.write({'state': 'escalated'})
|
||||||
|
|
||||||
def action_close(self):
|
def action_close(self):
|
||||||
|
"""Block close until lab evidence is on file.
|
||||||
|
|
||||||
|
A closed discharge sample without a lab report ref + at least
|
||||||
|
one parameter reading + (when results are in) a lab cert
|
||||||
|
attachment fails any environmental audit. The whole point
|
||||||
|
of the record is to document the test was performed and what
|
||||||
|
the lab said.
|
||||||
|
"""
|
||||||
|
for rec in self:
|
||||||
|
missing = []
|
||||||
|
if not rec.lab_report_ref:
|
||||||
|
missing.append(_('Lab Report #'))
|
||||||
|
if not rec.received_date:
|
||||||
|
missing.append(_('Results Received Date'))
|
||||||
|
if not rec.line_ids:
|
||||||
|
missing.append(_('At least one parameter reading'))
|
||||||
|
if not rec.attachment_ids:
|
||||||
|
missing.append(_('Lab certificate / report attachment'))
|
||||||
|
if missing:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot close discharge sample "%(name)s" — these '
|
||||||
|
'fields must be filled in first:\n • %(fields)s\n\n'
|
||||||
|
'Without lab evidence on file the record fails any '
|
||||||
|
'environmental compliance audit.'
|
||||||
|
) % {
|
||||||
|
'name': rec.name or rec.display_name,
|
||||||
|
'fields': '\n • '.join(missing),
|
||||||
|
})
|
||||||
self.write({'state': 'closed'})
|
self.write({'state': 'closed'})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.5.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
@@ -235,11 +237,11 @@ class FpPartCatalog(models.Model):
|
|||||||
old = snap['model']
|
old = snap['model']
|
||||||
new = rec.model_attachment_id
|
new = rec.model_attachment_id
|
||||||
if not old and new:
|
if not old and new:
|
||||||
messages.append(_('<b>3D model attached:</b> %s') % new.name)
|
messages.append(Markup(_('<b>3D model attached:</b> %s')) % new.name)
|
||||||
elif old and not new:
|
elif old and not new:
|
||||||
messages.append(_('<b>3D model removed:</b> %s') % old.name)
|
messages.append(Markup(_('<b>3D model removed:</b> %s')) % old.name)
|
||||||
elif old and new and old.id != new.id:
|
elif old and new and old.id != new.id:
|
||||||
messages.append(_('<b>3D model changed:</b> %s → %s') % (old.name, new.name))
|
messages.append(Markup(_('<b>3D model changed:</b> %s → %s')) % (old.name, new.name))
|
||||||
|
|
||||||
# Drawing changes (added or removed)
|
# Drawing changes (added or removed)
|
||||||
if track_drawings:
|
if track_drawings:
|
||||||
@@ -250,15 +252,15 @@ class FpPartCatalog(models.Model):
|
|||||||
for att_id in added:
|
for att_id in added:
|
||||||
att = self.env['ir.attachment'].browse(att_id)
|
att = self.env['ir.attachment'].browse(att_id)
|
||||||
if att.exists():
|
if att.exists():
|
||||||
messages.append(_('<b>Drawing attached:</b> %s') % att.name)
|
messages.append(Markup(_('<b>Drawing attached:</b> %s')) % att.name)
|
||||||
for att_id in removed:
|
for att_id in removed:
|
||||||
att = self.env['ir.attachment'].browse(att_id)
|
att = self.env['ir.attachment'].browse(att_id)
|
||||||
# Browse even if deleted — may still have name if not purged
|
# Browse even if deleted — may still have name if not purged
|
||||||
name = att.exists() and att.name or f'#{att_id}'
|
name = att.exists() and att.name or f'#{att_id}'
|
||||||
messages.append(_('<b>Drawing removed:</b> %s') % name)
|
messages.append(Markup(_('<b>Drawing removed:</b> %s')) % name)
|
||||||
|
|
||||||
if messages:
|
if messages:
|
||||||
body = '<br/>'.join(messages)
|
body = Markup('<br/>').join(messages)
|
||||||
# Post to part catalog chatter
|
# Post to part catalog chatter
|
||||||
rec.message_post(
|
rec.message_post(
|
||||||
body=body,
|
body=body,
|
||||||
@@ -271,7 +273,7 @@ class FpPartCatalog(models.Model):
|
|||||||
])
|
])
|
||||||
for cfg in configurators:
|
for cfg in configurators:
|
||||||
cfg.message_post(
|
cfg.message_post(
|
||||||
body=_('Part <b>%s</b>: %s') % (rec.name, body),
|
body=Markup(_('Part <b>%s</b>: %s')) % (rec.name, body),
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
subtype_xmlid='mail.mt_note',
|
subtype_xmlid='mail.mt_note',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
@@ -529,6 +531,11 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
|
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
|
||||||
'x_fc_po_number': self.po_number_preliminary or False,
|
'x_fc_po_number': self.po_number_preliminary or False,
|
||||||
'x_fc_po_received': bool(self.po_attachment_id),
|
'x_fc_po_received': bool(self.po_attachment_id),
|
||||||
|
# Mirror the PO# into Odoo's standard client_order_ref so
|
||||||
|
# the customer portal, every standard report, and every
|
||||||
|
# third-party integration can read the PO without knowing
|
||||||
|
# about our custom field.
|
||||||
|
'client_order_ref': self.po_number_preliminary or False,
|
||||||
'origin': self.name,
|
'origin': self.name,
|
||||||
'order_line': [(0, 0, {
|
'order_line': [(0, 0, {
|
||||||
'product_id': product.id,
|
'product_id': product.id,
|
||||||
@@ -544,7 +551,7 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'won_date': fields.Date.today(),
|
'won_date': fields.Date.today(),
|
||||||
})
|
})
|
||||||
self.message_post(
|
self.message_post(
|
||||||
body=_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.') % (so.id, so.name),
|
body=Markup(_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.')) % (so.id, so.name),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
'type': 'ir.actions.act_window',
|
'type': 'ir.actions.act_window',
|
||||||
@@ -618,7 +625,7 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
# Post to chatter so user sees confirmation (only if record is saved)
|
# Post to chatter so user sees confirmation (only if record is saved)
|
||||||
if self.id and not isinstance(self.id, models.NewId):
|
if self.id and not isinstance(self.id, models.NewId):
|
||||||
self.sudo().message_post(
|
self.sudo().message_post(
|
||||||
body=_('3D model attached: <b>%s</b> — surface area: %.4f %s') % (
|
body=Markup(_('3D model attached: <b>%s</b> — surface area: %.4f %s')) % (
|
||||||
fname, self.surface_area, self.surface_area_uom or ''),
|
fname, self.surface_area, self.surface_area_uom or ''),
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
subtype_xmlid='mail.mt_note',
|
subtype_xmlid='mail.mt_note',
|
||||||
@@ -661,7 +668,7 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
# Post to chatter so user sees confirmation (only if record is saved)
|
# Post to chatter so user sees confirmation (only if record is saved)
|
||||||
if self.id and not isinstance(self.id, models.NewId):
|
if self.id and not isinstance(self.id, models.NewId):
|
||||||
self.sudo().message_post(
|
self.sudo().message_post(
|
||||||
body=_('Drawing attached: <b>%s</b> (linked to part %s)') % (
|
body=Markup(_('Drawing attached: <b>%s</b> (linked to part %s)')) % (
|
||||||
fname, part.name),
|
fname, part.name),
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
subtype_xmlid='mail.mt_note',
|
subtype_xmlid='mail.mt_note',
|
||||||
@@ -833,7 +840,7 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'complexity': self.complexity,
|
'complexity': self.complexity,
|
||||||
})
|
})
|
||||||
self.message_post(
|
self.message_post(
|
||||||
body=_('Geometry and material saved back to part catalog <b>%s</b>.') % self.part_catalog_id.name,
|
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
subtype_xmlid='mail.mt_note',
|
subtype_xmlid='mail.mt_note',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,54 +4,18 @@
|
|||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// -- Configurator two-column layout: 3/4 fields + 1/4 preview --
|
// -- Configurator layout (single column) -------------------------------------
|
||||||
// When the preview column is hidden (no 3D model AND no drawings), the
|
// The right-side 3D viewer + drawing preview were retired in favour of
|
||||||
// fields column expands to full width via the :has() selector below.
|
// smart-button + inline-Preview-link affordances. Layout collapses to a
|
||||||
|
// single full-width column. Wrapper kept so the SCSS hook stays stable
|
||||||
|
// in case we add a side panel back later.
|
||||||
.o_fp_cfg_layout {
|
.o_fp_cfg_layout {
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: 1fr 380px;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full width when right column has no visible content
|
|
||||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview.o_invisible_modifier),
|
|
||||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display: none"]),
|
|
||||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display:none"]) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_cfg_fields {
|
.o_fp_cfg_fields {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_cfg_preview {
|
|
||||||
position: sticky;
|
|
||||||
top: 16px;
|
|
||||||
|
|
||||||
// Force all field widgets (3D viewer, Html drawing preview) to be
|
|
||||||
// block-level + full width so the 3D and PDF iframes match exactly.
|
|
||||||
.o_field_widget,
|
|
||||||
> div > .o_field_widget {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive: stack on narrow screens
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.o_fp_cfg_layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.o_fp_cfg_preview {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- 3D viewer widget --
|
// -- 3D viewer widget --
|
||||||
.o_fp_3d_viewer_root {
|
.o_fp_3d_viewer_root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -66,6 +66,22 @@
|
|||||||
invisible="not part_catalog_id">
|
invisible="not part_catalog_id">
|
||||||
<field name="part_catalog_id" widget="statinfo" string="Part"/>
|
<field name="part_catalog_id" widget="statinfo" string="Part"/>
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
|
3D Model + Drawings smart buttons.
|
||||||
|
Both open a modal preview (action_open_3d_fullscreen
|
||||||
|
and action_view_drawings) that replaces what used
|
||||||
|
to be the right-column inline previews.
|
||||||
|
-->
|
||||||
|
<button name="action_open_3d_fullscreen"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-cube"
|
||||||
|
invisible="not model_attachment_id">
|
||||||
|
<div class="o_stat_info">
|
||||||
|
<span class="o_stat_value">1</span>
|
||||||
|
<span class="o_stat_text">3D Model</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
<button name="action_view_drawings"
|
<button name="action_view_drawings"
|
||||||
type="object"
|
type="object"
|
||||||
class="oe_stat_button"
|
class="oe_stat_button"
|
||||||
@@ -100,9 +116,14 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main layout: 3/4 fields (left) + 1/4 3D preview (right) -->
|
<!--
|
||||||
|
Single-column layout. The right-side 3D viewer +
|
||||||
|
Drawing preview were removed (commit pending) — both
|
||||||
|
live behind the 3D Model / Drawings smart buttons at
|
||||||
|
the top of the form, plus inline "Preview" links
|
||||||
|
next to each respective field.
|
||||||
|
-->
|
||||||
<div class="o_fp_cfg_layout">
|
<div class="o_fp_cfg_layout">
|
||||||
<!-- LEFT COLUMN: all fields -->
|
|
||||||
<div class="o_fp_cfg_fields">
|
<div class="o_fp_cfg_fields">
|
||||||
<group>
|
<group>
|
||||||
<group string="Customer & Part">
|
<group string="Customer & Part">
|
||||||
@@ -114,19 +135,41 @@
|
|||||||
invisible="state != 'draft' or model_attachment_id"
|
invisible="state != 'draft' or model_attachment_id"
|
||||||
string="Attach 3D File"/>
|
string="Attach 3D File"/>
|
||||||
<field name="upload_3d_filename" invisible="1"/>
|
<field name="upload_3d_filename" invisible="1"/>
|
||||||
<field name="model_attachment_id"
|
<!--
|
||||||
string="3D Model"
|
3D Model + inline Preview link. Field shows
|
||||||
invisible="not model_attachment_id"
|
the attachment name, the small Preview link
|
||||||
readonly="state != 'draft'"/>
|
opens the same fullscreen wizard as the
|
||||||
<!-- Drawing: upload before, filename + clear button after -->
|
smart button at the top of the form.
|
||||||
|
-->
|
||||||
|
<label for="model_attachment_id" string="3D Model"
|
||||||
|
invisible="not model_attachment_id"/>
|
||||||
|
<div class="o_row" invisible="not model_attachment_id">
|
||||||
|
<field name="model_attachment_id" nolabel="1"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<button name="action_open_3d_fullscreen"
|
||||||
|
type="object"
|
||||||
|
string="Preview"
|
||||||
|
icon="fa-eye"
|
||||||
|
class="btn btn-link btn-sm ms-2 p-0"
|
||||||
|
title="Open 3D model preview"/>
|
||||||
|
</div>
|
||||||
|
<!-- Drawing: upload before, filename + Preview link after -->
|
||||||
<field name="upload_drawing" filename="upload_drawing_filename"
|
<field name="upload_drawing" filename="upload_drawing_filename"
|
||||||
invisible="state != 'draft' or drawing_count > 0"
|
invisible="state != 'draft' or drawing_count > 0"
|
||||||
string="Attach Drawing"/>
|
string="Attach Drawing"/>
|
||||||
<field name="upload_drawing_filename" invisible="1"/>
|
<field name="upload_drawing_filename" invisible="1"/>
|
||||||
<field name="first_drawing_id"
|
<label for="first_drawing_id" string="Drawing"
|
||||||
string="Drawing"
|
invisible="drawing_count == 0"/>
|
||||||
invisible="drawing_count == 0"
|
<div class="o_row" invisible="drawing_count == 0">
|
||||||
readonly="state != 'draft'"/>
|
<field name="first_drawing_id" nolabel="1"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
<button name="action_view_drawings"
|
||||||
|
type="object"
|
||||||
|
string="Preview"
|
||||||
|
icon="fa-eye"
|
||||||
|
class="btn btn-link btn-sm ms-2 p-0"
|
||||||
|
title="Open drawing preview"/>
|
||||||
|
</div>
|
||||||
<field name="drawing_count" invisible="1"/>
|
<field name="drawing_count" invisible="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="RFQ / PO Documents">
|
<group string="RFQ / PO Documents">
|
||||||
@@ -149,27 +192,22 @@
|
|||||||
<field name="po_number_preliminary"
|
<field name="po_number_preliminary"
|
||||||
string="PO Number"
|
string="PO Number"
|
||||||
readonly="state != 'draft'"/>
|
readonly="state != 'draft'"/>
|
||||||
<separator string="Quantity & Options"/>
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Row 2 — Quantity / Options on the LEFT, Auto-from-3D on
|
||||||
|
the RIGHT (visible only when a part catalog is linked).
|
||||||
|
Quantity moved out of the RFQ/PO group so the right
|
||||||
|
column has a peer instead of stretching alone.
|
||||||
|
-->
|
||||||
|
<group>
|
||||||
|
<group string="Quantity & Options">
|
||||||
<field name="quantity"/>
|
<field name="quantity"/>
|
||||||
<field name="batch_size"/>
|
<field name="batch_size"/>
|
||||||
<field name="complexity"/>
|
<field name="complexity"/>
|
||||||
<field name="rush_order"/>
|
<field name="rush_order"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<group string="Geometry">
|
|
||||||
<field name="surface_area"/>
|
|
||||||
<field name="surface_area_uom"/>
|
|
||||||
<field name="masking_area_sqin"
|
|
||||||
string="Masking Area (sq in)"/>
|
|
||||||
<field name="effective_area_sqin"
|
|
||||||
string="Effective Plating Area"
|
|
||||||
readonly="1"/>
|
|
||||||
<field name="thickness_requested"/>
|
|
||||||
<field name="substrate_material"/>
|
|
||||||
<field name="masking_zones"/>
|
|
||||||
<field name="turnaround_days"/>
|
|
||||||
</group>
|
|
||||||
<group string="Auto from 3D"
|
<group string="Auto from 3D"
|
||||||
invisible="not part_catalog_id">
|
invisible="not part_catalog_id">
|
||||||
<field name="bbox_summary_in"
|
<field name="bbox_summary_in"
|
||||||
@@ -189,13 +227,34 @@
|
|||||||
readonly="1"/>
|
readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<div class="alert alert-warning"
|
<div class="alert alert-warning"
|
||||||
invisible="is_manifold or not part_catalog_id or not hole_count">
|
invisible="is_manifold or not part_catalog_id or not hole_count">
|
||||||
<i class="fa fa-exclamation-triangle me-1"/>
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
<strong>Warning:</strong> 3D model is not watertight.
|
<strong>Warning:</strong> 3D model is not watertight.
|
||||||
Surface area calculation may be inaccurate. Review the file before quoting.
|
Surface area calculation may be inaccurate. Review the file before quoting.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Row 3 — Geometry on the LEFT, Delivery & Fees on the
|
||||||
|
RIGHT. Delivery/Fees used to live in its own row with
|
||||||
|
an empty right side; pairing it with Geometry keeps
|
||||||
|
both columns balanced.
|
||||||
|
-->
|
||||||
<group>
|
<group>
|
||||||
|
<group string="Geometry">
|
||||||
|
<field name="surface_area"/>
|
||||||
|
<field name="surface_area_uom"/>
|
||||||
|
<field name="masking_area_sqin"
|
||||||
|
string="Masking Area (sq in)"/>
|
||||||
|
<field name="effective_area_sqin"
|
||||||
|
string="Effective Plating Area"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="thickness_requested"/>
|
||||||
|
<field name="substrate_material"/>
|
||||||
|
<field name="masking_zones"/>
|
||||||
|
<field name="turnaround_days"/>
|
||||||
|
</group>
|
||||||
<group string="Delivery & Fees">
|
<group string="Delivery & Fees">
|
||||||
<field name="delivery_method"/>
|
<field name="delivery_method"/>
|
||||||
<field name="currency_id" invisible="1"/>
|
<field name="currency_id" invisible="1"/>
|
||||||
@@ -222,37 +281,6 @@
|
|||||||
</group>
|
</group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: 3D preview + Drawings preview (sticky) -->
|
|
||||||
<div class="o_fp_cfg_preview"
|
|
||||||
invisible="not model_attachment_id and drawing_count == 0">
|
|
||||||
<!-- 3D viewer -->
|
|
||||||
<div invisible="not model_attachment_id">
|
|
||||||
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
|
||||||
<div class="text-center mt-2">
|
|
||||||
<button name="action_open_3d_fullscreen"
|
|
||||||
string="Full Screen"
|
|
||||||
type="object"
|
|
||||||
class="btn btn-sm btn-outline-primary"
|
|
||||||
icon="fa-expand"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Drawings preview (custom OWL widget — fixed height, full screen button) -->
|
|
||||||
<div invisible="drawing_count == 0" class="mt-3">
|
|
||||||
<span class="o_form_label fw-bold text-muted small d-block mb-1">Drawing Preview</span>
|
|
||||||
<field name="first_drawing_id"
|
|
||||||
widget="fp_pdf_inline_preview"
|
|
||||||
nolabel="1"
|
|
||||||
readonly="1"/>
|
|
||||||
<!-- Multi-drawing list shown only when more than one -->
|
|
||||||
<div invisible="drawing_count < 2" class="mt-2">
|
|
||||||
<span class="o_form_label fw-bold text-muted small d-block mb-1">All Drawings</span>
|
|
||||||
<field name="drawing_attachment_ids"
|
|
||||||
widget="fp_pdf_preview_binary"
|
|
||||||
nolabel="1"
|
|
||||||
readonly="1"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<notebook>
|
<notebook>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Invoicing',
|
'name': 'Fusion Plating — Invoicing',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.2.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -3,15 +3,56 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import models, _
|
from odoo import api, models, _
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class AccountMove(models.Model):
|
class AccountMove(models.Model):
|
||||||
_inherit = 'account.move'
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Auto-inherit payment terms + customer PO# at creation time.
|
||||||
|
|
||||||
|
Two defensive defaults so newly-created invoices come out
|
||||||
|
compliant out of the box:
|
||||||
|
|
||||||
|
1. **invoice_payment_term_id** — pulled from the customer's
|
||||||
|
property_payment_term_id (Net-30, COD, etc.). Without this
|
||||||
|
the due date silently becomes "immediate", wrong for B2B.
|
||||||
|
|
||||||
|
2. **ref** (customer reference / PO#) — pulled from the source
|
||||||
|
sale order's client_order_ref or x_fc_po_number. Customer
|
||||||
|
AP teams reject invoices that don't quote their PO# back.
|
||||||
|
We already populate this on the SO confirm path, but a
|
||||||
|
manually-created invoice would miss it without this default.
|
||||||
|
"""
|
||||||
|
Partner = self.env['res.partner']
|
||||||
|
SO = self.env['sale.order']
|
||||||
|
for vals in vals_list:
|
||||||
|
if vals.get('move_type') in ('out_invoice', 'out_refund'):
|
||||||
|
if not vals.get('invoice_payment_term_id') and vals.get('partner_id'):
|
||||||
|
partner = Partner.browse(vals['partner_id'])
|
||||||
|
if partner.property_payment_term_id:
|
||||||
|
vals['invoice_payment_term_id'] = partner.property_payment_term_id.id
|
||||||
|
# Defensive PO#: invoice_origin links to the SO; pull the
|
||||||
|
# customer ref from there if the caller didn't pass one.
|
||||||
|
if not vals.get('ref') and vals.get('invoice_origin'):
|
||||||
|
so = SO.search([('name', '=', vals['invoice_origin'])], limit=1)
|
||||||
|
if so:
|
||||||
|
vals['ref'] = (
|
||||||
|
so.client_order_ref
|
||||||
|
or (so.x_fc_po_number if 'x_fc_po_number' in so._fields else False)
|
||||||
|
or False
|
||||||
|
)
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
def action_post(self):
|
def action_post(self):
|
||||||
"""Check account hold before posting invoices."""
|
"""Block post when:
|
||||||
|
• customer is on account hold (existing rule), or
|
||||||
|
• the invoice has no payment term (auto-fill missed it AND
|
||||||
|
partner had no default — accountant must pick one).
|
||||||
|
"""
|
||||||
for move in self:
|
for move in self:
|
||||||
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
|
||||||
if move.partner_id.x_fc_account_hold:
|
if move.partner_id.x_fc_account_hold:
|
||||||
@@ -25,4 +66,11 @@ class AccountMove(models.Model):
|
|||||||
'Contact a manager to override.'
|
'Contact a manager to override.'
|
||||||
) % (move.partner_id.name,
|
) % (move.partner_id.name,
|
||||||
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
|
||||||
|
if not move.invoice_payment_term_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot post invoice "%s" — no payment terms set.\n\n'
|
||||||
|
'Pick payment terms (Net-30, COD, etc.) on the invoice, '
|
||||||
|
'or set a default on the customer "%s" so future '
|
||||||
|
'invoices inherit it automatically.'
|
||||||
|
) % (move.name or move.display_name, move.partner_id.name))
|
||||||
return super().action_post()
|
return super().action_post()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Logistics',
|
'name': 'Fusion Plating — Logistics',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': (
|
'summary': (
|
||||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import api, fields, models
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpDelivery(models.Model):
|
class FpDelivery(models.Model):
|
||||||
@@ -169,7 +170,21 @@ class FpDelivery(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def action_mark_delivered(self):
|
def action_mark_delivered(self):
|
||||||
|
"""Block "delivered" until a Proof of Delivery exists.
|
||||||
|
|
||||||
|
The driver must capture POD (signature, photos, recipient name)
|
||||||
|
on the iPad at the customer's dock BEFORE marking delivered.
|
||||||
|
Without POD we have no signed receipt to attach to the
|
||||||
|
invoice and no defence against a delivery dispute.
|
||||||
|
"""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
if not rec.pod_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot mark delivery "%(name)s" delivered — no Proof '
|
||||||
|
'of Delivery (POD) has been captured.\n\n'
|
||||||
|
'On the iPad: Capture POD → enter recipient name + '
|
||||||
|
'signature → save. Then mark delivered.'
|
||||||
|
) % {'name': rec.name or rec.display_name})
|
||||||
rec.write({
|
rec.write({
|
||||||
'state': 'delivered',
|
'state': 'delivered',
|
||||||
'delivered_at': fields.Datetime.now(),
|
'delivered_at': fields.Datetime.now(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Notifications',
|
'name': 'Fusion Plating — Notifications',
|
||||||
'version': '19.0.3.0.0',
|
'version': '19.0.4.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -218,25 +218,49 @@ class FpNotificationTemplate(models.Model):
|
|||||||
)
|
)
|
||||||
if att:
|
if att:
|
||||||
ids.append(att)
|
ids.append(att)
|
||||||
# CoC — gated by customer preference (x_fc_send_coc, default True)
|
# CoC — gated by customer preference (x_fc_send_coc, default True).
|
||||||
|
# Prefer the rich PDF that mrp_production.button_mark_done already
|
||||||
|
# rendered against the fp.certificate (signatures, accreditation
|
||||||
|
# logos, thickness data). The legacy action_report_coc bound to
|
||||||
|
# fusion.plating.portal.job is only a header table; never use it
|
||||||
|
# when a real cert PDF exists.
|
||||||
if self.attach_coc and portal_job and _customer_wants('x_fc_send_coc'):
|
if self.attach_coc and portal_job and _customer_wants('x_fc_send_coc'):
|
||||||
att = _render_report(
|
if portal_job.coc_attachment_id:
|
||||||
'fusion_plating_reports.action_report_coc', portal_job,
|
ids.append(portal_job.coc_attachment_id.id)
|
||||||
)
|
else:
|
||||||
if att:
|
# No pre-rendered cert (older job or cert-gen failed).
|
||||||
ids.append(att)
|
# Render the rich cert report against the most recent
|
||||||
# Thickness report — gated by customer preference. Today the CoC
|
# CoC fp.certificate, falling back to the bare portal_job
|
||||||
# template embeds thickness readings, so when a customer wants
|
# template only if no cert exists at all.
|
||||||
# thickness-only we fall back to the CoC report attachment with
|
Cert = self.env.get('fp.certificate')
|
||||||
# a distinct filename. A standalone thickness-only template is
|
cert = False
|
||||||
# TBD (not part of this chunk).
|
if Cert is not None and production:
|
||||||
|
cert = Cert.search([
|
||||||
|
('production_id', '=', production.id),
|
||||||
|
('certificate_type', '=', 'coc'),
|
||||||
|
], order='id desc', limit=1)
|
||||||
|
if cert:
|
||||||
|
lang = (cert.partner_id.lang or '').lower()
|
||||||
|
cert_xmlid = (
|
||||||
|
'fusion_plating_reports.action_report_coc_fr'
|
||||||
|
if lang.startswith('fr')
|
||||||
|
else 'fusion_plating_reports.action_report_coc_en'
|
||||||
|
)
|
||||||
|
att = _render_report(cert_xmlid, cert)
|
||||||
|
else:
|
||||||
|
att = _render_report(
|
||||||
|
'fusion_plating_reports.action_report_coc', portal_job,
|
||||||
|
)
|
||||||
|
if att:
|
||||||
|
ids.append(att)
|
||||||
|
# Thickness report — only attach when the customer opted OUT of
|
||||||
|
# CoC and ONLY wants thickness. The CoC PDF already embeds
|
||||||
|
# thickness data so attaching both would be a duplicate.
|
||||||
if (self.attach_thickness_report and portal_job
|
if (self.attach_thickness_report and portal_job
|
||||||
and _customer_wants('x_fc_send_thickness_report')
|
and _customer_wants('x_fc_send_thickness_report')
|
||||||
and not (self.attach_coc and _customer_wants('x_fc_send_coc'))):
|
and not (self.attach_coc and _customer_wants('x_fc_send_coc'))):
|
||||||
# Avoid double-attaching the same PDF when both are wanted —
|
|
||||||
# the CoC already carries the thickness data.
|
|
||||||
att = _render_report(
|
att = _render_report(
|
||||||
'fusion_plating_reports.action_report_coc', portal_job,
|
'fusion_plating_reports.action_report_coc_en', portal_job,
|
||||||
)
|
)
|
||||||
if att:
|
if att:
|
||||||
ids.append(att)
|
ids.append(att)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Customer Portal',
|
'name': 'Fusion Plating — Customer Portal',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.2.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||||
'CoC downloads, invoice access.',
|
'CoC downloads, invoice access.',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user