algo-cli-runtime 0.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- ollama_cli/__init__.py +67 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""B91/B92/B93. AP duplicate, vendor SoD, and AR aging engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from .common import ReviewGate, RiskLevel, SourceRef, decimalize, normalize_invoice_number, normalize_vendor_name, stable_exception_id, within_tolerance
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class APInvoice:
|
|
16
|
+
id: str
|
|
17
|
+
vendor: str
|
|
18
|
+
invoice_number: str
|
|
19
|
+
amount: Decimal | int | str
|
|
20
|
+
invoice_date: date | str
|
|
21
|
+
po_id: str | None = None
|
|
22
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
object.__setattr__(self, "amount", decimalize(self.amount))
|
|
26
|
+
if isinstance(self.invoice_date, str):
|
|
27
|
+
object.__setattr__(self, "invoice_date", datetime.fromisoformat(self.invoice_date[:10]).date())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class APPayment:
|
|
32
|
+
id: str
|
|
33
|
+
vendor: str
|
|
34
|
+
amount: Decimal | int | str
|
|
35
|
+
payment_date: date | str
|
|
36
|
+
invoice_number: str | None = None
|
|
37
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
object.__setattr__(self, "amount", decimalize(self.amount))
|
|
41
|
+
if isinstance(self.payment_date, str):
|
|
42
|
+
object.__setattr__(self, "payment_date", datetime.fromisoformat(self.payment_date[:10]).date())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class DuplicateCandidate:
|
|
47
|
+
invoice_ids: list[str]
|
|
48
|
+
payment_ids: list[str]
|
|
49
|
+
confidence: Decimal
|
|
50
|
+
reasons: list[str]
|
|
51
|
+
source_refs: list[SourceRef]
|
|
52
|
+
review_gate: ReviewGate = ReviewGate.CONTROLLER_REVIEW
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ThreeWayMatchResult:
|
|
57
|
+
clean: bool
|
|
58
|
+
exceptions: list[str]
|
|
59
|
+
amount_difference: Decimal
|
|
60
|
+
quantity_difference: Decimal
|
|
61
|
+
tolerance_used: Decimal
|
|
62
|
+
review_gate: ReviewGate = ReviewGate.CONTROLLER_REVIEW
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APDuplicateDetector:
|
|
66
|
+
"""Find duplicate AP candidates and three-way match exceptions."""
|
|
67
|
+
|
|
68
|
+
def find_duplicates(
|
|
69
|
+
self,
|
|
70
|
+
invoices: Iterable[APInvoice | dict[str, Any]],
|
|
71
|
+
payments: Iterable[APPayment | dict[str, Any]] | None = None,
|
|
72
|
+
amount_tolerance: Decimal | int | str = Decimal("0"),
|
|
73
|
+
days_window: int = 7,
|
|
74
|
+
) -> list[DuplicateCandidate]:
|
|
75
|
+
inv = [_coerce_invoice(row) for row in invoices]
|
|
76
|
+
pay = [_coerce_payment(row) for row in (payments or [])]
|
|
77
|
+
tolerance = decimalize(amount_tolerance)
|
|
78
|
+
candidates: list[DuplicateCandidate] = []
|
|
79
|
+
for i, left in enumerate(inv):
|
|
80
|
+
for right in inv[i + 1:]:
|
|
81
|
+
vendor_match = normalize_vendor_name(left.vendor) == normalize_vendor_name(right.vendor)
|
|
82
|
+
amount_match = within_tolerance(left.amount, right.amount, tolerance)
|
|
83
|
+
days = abs((left.invoice_date - right.invoice_date).days)
|
|
84
|
+
exact_invoice = normalize_invoice_number(left.invoice_number) == normalize_invoice_number(right.invoice_number)
|
|
85
|
+
near_invoice = _similar(left.invoice_number, right.invoice_number) >= Decimal("0.85")
|
|
86
|
+
if vendor_match and amount_match and (exact_invoice or (near_invoice and days <= days_window)):
|
|
87
|
+
reasons = ["same vendor", "same amount"]
|
|
88
|
+
confidence = Decimal("1.00") if exact_invoice else Decimal("0.75")
|
|
89
|
+
reasons.append("same normalized invoice number" if exact_invoice else "near invoice number")
|
|
90
|
+
candidates.append(DuplicateCandidate(
|
|
91
|
+
invoice_ids=[left.id, right.id],
|
|
92
|
+
payment_ids=[],
|
|
93
|
+
confidence=confidence,
|
|
94
|
+
reasons=reasons,
|
|
95
|
+
source_refs=[*left.source_refs, *right.source_refs],
|
|
96
|
+
))
|
|
97
|
+
for payment in pay:
|
|
98
|
+
linked = [invoice for invoice in inv if normalize_vendor_name(invoice.vendor) == normalize_vendor_name(payment.vendor)
|
|
99
|
+
and within_tolerance(invoice.amount, payment.amount, tolerance)
|
|
100
|
+
and (not payment.invoice_number or normalize_invoice_number(invoice.invoice_number) == normalize_invoice_number(payment.invoice_number))]
|
|
101
|
+
if len(linked) > 1:
|
|
102
|
+
candidates.append(DuplicateCandidate(
|
|
103
|
+
invoice_ids=[invoice.id for invoice in linked],
|
|
104
|
+
payment_ids=[payment.id],
|
|
105
|
+
confidence=Decimal("0.80"),
|
|
106
|
+
reasons=["payment could apply to multiple matching invoices"],
|
|
107
|
+
source_refs=[*payment.source_refs, *[ref for invoice in linked for ref in invoice.source_refs]],
|
|
108
|
+
))
|
|
109
|
+
return sorted(candidates, key=lambda row: (row.confidence, row.invoice_ids), reverse=True)
|
|
110
|
+
|
|
111
|
+
def three_way_match(
|
|
112
|
+
self,
|
|
113
|
+
po: dict[str, Any],
|
|
114
|
+
receipt: dict[str, Any],
|
|
115
|
+
invoice: dict[str, Any],
|
|
116
|
+
price_tolerance: Decimal | int | str = Decimal("0.01"),
|
|
117
|
+
quantity_tolerance: Decimal | int | str = Decimal("0"),
|
|
118
|
+
) -> ThreeWayMatchResult:
|
|
119
|
+
price_tol = decimalize(price_tolerance)
|
|
120
|
+
qty_tol = decimalize(quantity_tolerance)
|
|
121
|
+
po_qty = decimalize(po.get("quantity", 0))
|
|
122
|
+
receipt_qty = decimalize(receipt.get("quantity", 0))
|
|
123
|
+
invoice_qty = decimalize(invoice.get("quantity", 0))
|
|
124
|
+
po_price = decimalize(po.get("unit_price", 0))
|
|
125
|
+
invoice_price = decimalize(invoice.get("unit_price", 0))
|
|
126
|
+
exceptions: list[str] = []
|
|
127
|
+
if not within_tolerance(invoice_qty, receipt_qty, qty_tol):
|
|
128
|
+
exceptions.append("invoice quantity does not match receipt quantity")
|
|
129
|
+
if invoice_qty > po_qty + qty_tol:
|
|
130
|
+
exceptions.append("invoice quantity exceeds PO quantity")
|
|
131
|
+
if not within_tolerance(invoice_price, po_price, price_tol):
|
|
132
|
+
exceptions.append("invoice unit price does not match PO unit price")
|
|
133
|
+
po_amount = po_qty * po_price
|
|
134
|
+
invoice_amount = invoice_qty * invoice_price
|
|
135
|
+
amount_difference = invoice_amount - po_amount
|
|
136
|
+
quantity_difference = invoice_qty - receipt_qty
|
|
137
|
+
return ThreeWayMatchResult(
|
|
138
|
+
clean=not exceptions,
|
|
139
|
+
exceptions=exceptions,
|
|
140
|
+
amount_difference=amount_difference,
|
|
141
|
+
quantity_difference=quantity_difference,
|
|
142
|
+
tolerance_used=max(price_tol, qty_tol),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Vendor:
|
|
148
|
+
id: str
|
|
149
|
+
name: str
|
|
150
|
+
tax_id: str | None = None
|
|
151
|
+
bank_account: str | None = None
|
|
152
|
+
created_by: str | None = None
|
|
153
|
+
approved_by: str | None = None
|
|
154
|
+
active: bool = True
|
|
155
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class VendorChangeEvent:
|
|
160
|
+
id: str
|
|
161
|
+
vendor_id: str
|
|
162
|
+
field: str
|
|
163
|
+
changed_by: str
|
|
164
|
+
approved_by: str | None = None
|
|
165
|
+
changed_at: datetime | str | None = None
|
|
166
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
def __post_init__(self) -> None:
|
|
169
|
+
if isinstance(self.changed_at, str):
|
|
170
|
+
object.__setattr__(self, "changed_at", datetime.fromisoformat(self.changed_at))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class VendorRisk:
|
|
175
|
+
vendor_id: str
|
|
176
|
+
score: Decimal
|
|
177
|
+
level: RiskLevel
|
|
178
|
+
reasons: list[str]
|
|
179
|
+
source_refs: list[SourceRef]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class SoDConflict:
|
|
184
|
+
id: str
|
|
185
|
+
vendor_id: str
|
|
186
|
+
user: str
|
|
187
|
+
action: str
|
|
188
|
+
message: str
|
|
189
|
+
source_refs: list[SourceRef]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class VendorRiskAnalyzer:
|
|
193
|
+
def score(
|
|
194
|
+
self,
|
|
195
|
+
vendors: Iterable[Vendor | dict[str, Any]],
|
|
196
|
+
changes: Iterable[VendorChangeEvent | dict[str, Any]] = (),
|
|
197
|
+
users: dict[str, Any] | None = None,
|
|
198
|
+
) -> list[VendorRisk]:
|
|
199
|
+
vendor_rows = [_coerce_vendor(vendor) for vendor in vendors]
|
|
200
|
+
change_rows = [_coerce_change(change) for change in changes]
|
|
201
|
+
changes_by_vendor: dict[str, list[VendorChangeEvent]] = {}
|
|
202
|
+
for change in change_rows:
|
|
203
|
+
changes_by_vendor.setdefault(change.vendor_id, []).append(change)
|
|
204
|
+
risks: list[VendorRisk] = []
|
|
205
|
+
for vendor in vendor_rows:
|
|
206
|
+
score = Decimal("0")
|
|
207
|
+
reasons: list[str] = []
|
|
208
|
+
if not vendor.tax_id:
|
|
209
|
+
score += Decimal("150")
|
|
210
|
+
reasons.append("missing tax id")
|
|
211
|
+
if vendor.created_by and vendor.approved_by and vendor.created_by == vendor.approved_by:
|
|
212
|
+
score += Decimal("500")
|
|
213
|
+
reasons.append("creator approved vendor")
|
|
214
|
+
if not vendor.bank_account:
|
|
215
|
+
score += Decimal("100")
|
|
216
|
+
reasons.append("missing bank account")
|
|
217
|
+
sensitive_changes = [c for c in changes_by_vendor.get(vendor.id, []) if c.field.lower() in {"bank_account", "tax_id", "address"}]
|
|
218
|
+
if sensitive_changes:
|
|
219
|
+
score += Decimal("100") * len(sensitive_changes)
|
|
220
|
+
reasons.append("sensitive vendor master changes")
|
|
221
|
+
level = RiskLevel.CRITICAL if score >= 700 else RiskLevel.HIGH if score >= 400 else RiskLevel.MEDIUM if score >= 150 else RiskLevel.LOW
|
|
222
|
+
risks.append(VendorRisk(vendor_id=vendor.id, score=score, level=level, reasons=reasons, source_refs=list(vendor.source_refs)))
|
|
223
|
+
return sorted(risks, key=lambda row: (row.score, row.vendor_id), reverse=True)
|
|
224
|
+
|
|
225
|
+
def detect_sod_conflicts(self, events: Iterable[VendorChangeEvent | dict[str, Any]]) -> list[SoDConflict]:
|
|
226
|
+
conflicts: list[SoDConflict] = []
|
|
227
|
+
for event in [_coerce_change(e) for e in events]:
|
|
228
|
+
if event.approved_by and event.changed_by == event.approved_by:
|
|
229
|
+
conflicts.append(SoDConflict(
|
|
230
|
+
id=stable_exception_id("B92", [event.vendor_id, event.id, event.changed_by]),
|
|
231
|
+
vendor_id=event.vendor_id,
|
|
232
|
+
user=event.changed_by,
|
|
233
|
+
action=event.field,
|
|
234
|
+
message=f"Same user changed and approved vendor {event.vendor_id}",
|
|
235
|
+
source_refs=list(event.source_refs),
|
|
236
|
+
))
|
|
237
|
+
return conflicts
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class ARInvoice:
|
|
242
|
+
id: str
|
|
243
|
+
customer: str
|
|
244
|
+
amount: Decimal | int | str
|
|
245
|
+
invoice_date: date | str
|
|
246
|
+
due_date: date | str
|
|
247
|
+
disputed: bool = False
|
|
248
|
+
customer_risk: RiskLevel = RiskLevel.MEDIUM
|
|
249
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
250
|
+
|
|
251
|
+
def __post_init__(self) -> None:
|
|
252
|
+
object.__setattr__(self, "amount", decimalize(self.amount))
|
|
253
|
+
if isinstance(self.invoice_date, str):
|
|
254
|
+
object.__setattr__(self, "invoice_date", datetime.fromisoformat(self.invoice_date[:10]).date())
|
|
255
|
+
if isinstance(self.due_date, str):
|
|
256
|
+
object.__setattr__(self, "due_date", datetime.fromisoformat(self.due_date[:10]).date())
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class AgingBucket:
|
|
261
|
+
name: str
|
|
262
|
+
invoices: list[ARInvoice]
|
|
263
|
+
total: Decimal
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class AgingReport:
|
|
268
|
+
as_of: date
|
|
269
|
+
buckets: dict[str, AgingBucket]
|
|
270
|
+
total: Decimal
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class CollectionAction:
|
|
275
|
+
invoice_id: str
|
|
276
|
+
customer: str
|
|
277
|
+
priority_score: Decimal
|
|
278
|
+
reason: str
|
|
279
|
+
source_refs: list[SourceRef]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class ARAgingPrioritizer:
|
|
283
|
+
def age(self, invoices: Iterable[ARInvoice | dict[str, Any]], as_of: date | str) -> AgingReport:
|
|
284
|
+
as_of_date = datetime.fromisoformat(as_of[:10]).date() if isinstance(as_of, str) else as_of
|
|
285
|
+
rows = [_coerce_ar_invoice(invoice) for invoice in invoices]
|
|
286
|
+
bucket_rows: dict[str, list[ARInvoice]] = {"current": [], "1-30": [], "31-60": [], "61-90": [], "90+": []}
|
|
287
|
+
for invoice in rows:
|
|
288
|
+
days = (as_of_date - invoice.due_date).days
|
|
289
|
+
bucket = "current" if days <= 0 else "1-30" if days <= 30 else "31-60" if days <= 60 else "61-90" if days <= 90 else "90+"
|
|
290
|
+
bucket_rows[bucket].append(invoice)
|
|
291
|
+
buckets = {
|
|
292
|
+
name: AgingBucket(name=name, invoices=values, total=sum((invoice.amount for invoice in values), Decimal("0")))
|
|
293
|
+
for name, values in bucket_rows.items()
|
|
294
|
+
}
|
|
295
|
+
return AgingReport(as_of=as_of_date, buckets=buckets, total=sum((invoice.amount for invoice in rows), Decimal("0")))
|
|
296
|
+
|
|
297
|
+
def prioritize_collections(self, invoices: Iterable[ARInvoice | dict[str, Any]], payments=None, as_of: date | str | None = None) -> list[CollectionAction]:
|
|
298
|
+
as_of_date = datetime.fromisoformat(as_of[:10]).date() if isinstance(as_of, str) else as_of or date.today()
|
|
299
|
+
actions: list[CollectionAction] = []
|
|
300
|
+
for invoice in [_coerce_ar_invoice(row) for row in invoices]:
|
|
301
|
+
days_past_due = max((as_of_date - invoice.due_date).days, 0)
|
|
302
|
+
score = abs(invoice.amount) + Decimal(days_past_due) * Decimal("25") + Decimal(int(invoice.customer_risk)) * Decimal("200")
|
|
303
|
+
reasons = [f"{days_past_due} days past due", f"{invoice.customer_risk.name.lower()} customer risk"]
|
|
304
|
+
if invoice.disputed:
|
|
305
|
+
score += Decimal("500")
|
|
306
|
+
reasons.append("disputed")
|
|
307
|
+
actions.append(CollectionAction(
|
|
308
|
+
invoice_id=invoice.id,
|
|
309
|
+
customer=invoice.customer,
|
|
310
|
+
priority_score=score,
|
|
311
|
+
reason="; ".join(reasons),
|
|
312
|
+
source_refs=list(invoice.source_refs),
|
|
313
|
+
))
|
|
314
|
+
return sorted(actions, key=lambda row: (row.priority_score, row.invoice_id), reverse=True)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _coerce_invoice(row: APInvoice | dict[str, Any]) -> APInvoice:
|
|
318
|
+
if isinstance(row, APInvoice):
|
|
319
|
+
return row
|
|
320
|
+
return APInvoice(str(row["id"]), str(row.get("vendor", "")), str(row.get("invoice_number", "")), row.get("amount", 0), row.get("invoice_date") or row.get("date"), row.get("po_id"), list(row.get("source_refs", [])))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _coerce_payment(row: APPayment | dict[str, Any]) -> APPayment:
|
|
324
|
+
if isinstance(row, APPayment):
|
|
325
|
+
return row
|
|
326
|
+
return APPayment(str(row["id"]), str(row.get("vendor", "")), row.get("amount", 0), row.get("payment_date") or row.get("date"), row.get("invoice_number"), list(row.get("source_refs", [])))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _similar(left: str, right: str) -> Decimal:
|
|
330
|
+
return decimalize(str(SequenceMatcher(None, normalize_invoice_number(left), normalize_invoice_number(right)).ratio()))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _coerce_vendor(row: Vendor | dict[str, Any]) -> Vendor:
|
|
334
|
+
if isinstance(row, Vendor):
|
|
335
|
+
return row
|
|
336
|
+
return Vendor(str(row["id"]), str(row.get("name", "")), row.get("tax_id"), row.get("bank_account"), row.get("created_by"), row.get("approved_by"), bool(row.get("active", True)), list(row.get("source_refs", [])))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _coerce_change(row: VendorChangeEvent | dict[str, Any]) -> VendorChangeEvent:
|
|
340
|
+
if isinstance(row, VendorChangeEvent):
|
|
341
|
+
return row
|
|
342
|
+
return VendorChangeEvent(str(row["id"]), str(row.get("vendor_id", "")), str(row.get("field", "")), str(row.get("changed_by", "")), row.get("approved_by"), row.get("changed_at"), list(row.get("source_refs", [])))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _coerce_ar_invoice(row: ARInvoice | dict[str, Any]) -> ARInvoice:
|
|
346
|
+
if isinstance(row, ARInvoice):
|
|
347
|
+
return row
|
|
348
|
+
risk = row.get("customer_risk", RiskLevel.MEDIUM)
|
|
349
|
+
if isinstance(risk, str):
|
|
350
|
+
risk = RiskLevel[risk.upper()]
|
|
351
|
+
return ARInvoice(str(row["id"]), str(row.get("customer", "")), row.get("amount", 0), row.get("invoice_date") or row.get("date"), row.get("due_date"), bool(row.get("disputed", False)), risk, list(row.get("source_refs", [])))
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""B94. 13-Week Cash Flow Forecast Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
from .common import ReviewGate, SourceRef, decimalize, stable_exception_id
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CashFlowItem:
|
|
15
|
+
id: str
|
|
16
|
+
amount: Decimal | int | str
|
|
17
|
+
due_date: date | str
|
|
18
|
+
category: str
|
|
19
|
+
description: str = ""
|
|
20
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
object.__setattr__(self, "amount", decimalize(self.amount))
|
|
24
|
+
if isinstance(self.due_date, str):
|
|
25
|
+
object.__setattr__(self, "due_date", datetime.fromisoformat(self.due_date[:10]).date())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CashForecastWeek:
|
|
30
|
+
week_number: int
|
|
31
|
+
week_start: date
|
|
32
|
+
beginning_cash: Decimal
|
|
33
|
+
receipts: Decimal
|
|
34
|
+
disbursements: Decimal
|
|
35
|
+
ending_cash: Decimal
|
|
36
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CashForecast:
|
|
41
|
+
starting_cash: Decimal
|
|
42
|
+
weeks: list[CashForecastWeek]
|
|
43
|
+
review_gate: ReviewGate = ReviewGate.CONTROLLER_REVIEW
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ending_cash(self) -> Decimal:
|
|
47
|
+
return self.weeks[-1].ending_cash if self.weeks else self.starting_cash
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ForecastVariance:
|
|
52
|
+
week_number: int
|
|
53
|
+
forecast_receipts: Decimal
|
|
54
|
+
actual_receipts: Decimal
|
|
55
|
+
forecast_disbursements: Decimal
|
|
56
|
+
actual_disbursements: Decimal
|
|
57
|
+
net_difference: Decimal
|
|
58
|
+
explanation: str
|
|
59
|
+
source_refs: list[SourceRef] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ForecastVarianceReport:
|
|
64
|
+
variances: list[ForecastVariance]
|
|
65
|
+
total_net_difference: Decimal
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ThirteenWeekCashForecast:
|
|
69
|
+
"""Weekly cash bridge: beginning cash + receipts - disbursements."""
|
|
70
|
+
|
|
71
|
+
def forecast(
|
|
72
|
+
self,
|
|
73
|
+
open_ar: Iterable[CashFlowItem | dict[str, Any]],
|
|
74
|
+
open_ap: Iterable[CashFlowItem | dict[str, Any]],
|
|
75
|
+
payroll: Iterable[CashFlowItem | dict[str, Any]],
|
|
76
|
+
recurring: Iterable[CashFlowItem | dict[str, Any]],
|
|
77
|
+
starting_cash: Decimal | int | str,
|
|
78
|
+
start_date: date | str | None = None,
|
|
79
|
+
) -> CashForecast:
|
|
80
|
+
start = _coerce_date(start_date) if start_date else date.today()
|
|
81
|
+
start = start - timedelta(days=start.weekday())
|
|
82
|
+
receipts = [_coerce_item(item, "ar") for item in open_ar]
|
|
83
|
+
disbursements = [_coerce_item(item, "ap") for item in list(open_ap) + list(payroll) + list(recurring)]
|
|
84
|
+
beginning = decimalize(starting_cash)
|
|
85
|
+
weeks: list[CashForecastWeek] = []
|
|
86
|
+
for idx in range(13):
|
|
87
|
+
week_start = start + timedelta(days=idx * 7)
|
|
88
|
+
week_end = week_start + timedelta(days=6)
|
|
89
|
+
week_receipts = [item for item in receipts if week_start <= item.due_date <= week_end]
|
|
90
|
+
week_disb = [item for item in disbursements if week_start <= item.due_date <= week_end]
|
|
91
|
+
receipt_total = sum((abs(item.amount) for item in week_receipts), Decimal("0"))
|
|
92
|
+
disb_total = sum((abs(item.amount) for item in week_disb), Decimal("0"))
|
|
93
|
+
ending = beginning + receipt_total - disb_total
|
|
94
|
+
weeks.append(CashForecastWeek(
|
|
95
|
+
week_number=idx + 1,
|
|
96
|
+
week_start=week_start,
|
|
97
|
+
beginning_cash=beginning,
|
|
98
|
+
receipts=receipt_total,
|
|
99
|
+
disbursements=disb_total,
|
|
100
|
+
ending_cash=ending,
|
|
101
|
+
source_refs=[ref for item in [*week_receipts, *week_disb] for ref in item.source_refs],
|
|
102
|
+
))
|
|
103
|
+
beginning = ending
|
|
104
|
+
return CashForecast(starting_cash=decimalize(starting_cash), weeks=weeks)
|
|
105
|
+
|
|
106
|
+
def variance(self, actuals: Iterable[dict[str, Any]], forecast: CashForecast) -> ForecastVarianceReport:
|
|
107
|
+
by_week = {week.week_number: week for week in forecast.weeks}
|
|
108
|
+
actual_by_week: dict[int, dict[str, Any]] = {}
|
|
109
|
+
for actual in actuals:
|
|
110
|
+
week = int(actual.get("week_number", 0))
|
|
111
|
+
actual_by_week[week] = actual
|
|
112
|
+
variances: list[ForecastVariance] = []
|
|
113
|
+
for week_number, week in by_week.items():
|
|
114
|
+
actual = actual_by_week.get(week_number, {})
|
|
115
|
+
actual_receipts = decimalize(actual.get("receipts", 0))
|
|
116
|
+
actual_disb = decimalize(actual.get("disbursements", 0))
|
|
117
|
+
if actual_receipts == 0 and actual_disb == 0 and week.receipts == 0 and week.disbursements == 0:
|
|
118
|
+
continue
|
|
119
|
+
net_diff = (actual_receipts - actual_disb) - (week.receipts - week.disbursements)
|
|
120
|
+
if net_diff == 0:
|
|
121
|
+
continue
|
|
122
|
+
explanation = _variance_explanation(week.receipts, actual_receipts, week.disbursements, actual_disb)
|
|
123
|
+
variances.append(ForecastVariance(
|
|
124
|
+
week_number=week_number,
|
|
125
|
+
forecast_receipts=week.receipts,
|
|
126
|
+
actual_receipts=actual_receipts,
|
|
127
|
+
forecast_disbursements=week.disbursements,
|
|
128
|
+
actual_disbursements=actual_disb,
|
|
129
|
+
net_difference=net_diff,
|
|
130
|
+
explanation=explanation,
|
|
131
|
+
source_refs=list(actual.get("source_refs", [])),
|
|
132
|
+
))
|
|
133
|
+
total = sum((variance.net_difference for variance in variances), Decimal("0"))
|
|
134
|
+
return ForecastVarianceReport(variances=variances, total_net_difference=total)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _coerce_item(item: CashFlowItem | dict[str, Any], default_category: str) -> CashFlowItem:
|
|
138
|
+
if isinstance(item, CashFlowItem):
|
|
139
|
+
return item
|
|
140
|
+
return CashFlowItem(
|
|
141
|
+
id=str(item.get("id") or stable_exception_id("B94", [item.get("amount"), item.get("due_date"), item.get("description")])) ,
|
|
142
|
+
amount=item.get("amount", 0),
|
|
143
|
+
due_date=item.get("due_date") or item.get("date"),
|
|
144
|
+
category=str(item.get("category", default_category)),
|
|
145
|
+
description=str(item.get("description", "")),
|
|
146
|
+
source_refs=list(item.get("source_refs", [])),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _coerce_date(value: date | str) -> date:
|
|
151
|
+
return datetime.fromisoformat(value[:10]).date() if isinstance(value, str) else value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _variance_explanation(f_receipts: Decimal, a_receipts: Decimal, f_disb: Decimal, a_disb: Decimal) -> str:
|
|
155
|
+
receipt_diff = a_receipts - f_receipts
|
|
156
|
+
disb_diff = a_disb - f_disb
|
|
157
|
+
parts: list[str] = []
|
|
158
|
+
if receipt_diff:
|
|
159
|
+
parts.append("receipt timing/amount variance")
|
|
160
|
+
if disb_diff:
|
|
161
|
+
parts.append("disbursement timing/amount variance")
|
|
162
|
+
return "; ".join(parts) if parts else "no variance"
|