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.
Files changed (237) hide show
  1. algo_cli/__init__.py +3 -0
  2. algo_cli/__main__.py +7 -0
  3. algo_cli/_internal/__init__.py +12 -0
  4. algo_cli/_internal/policy_chain.py +259 -0
  5. algo_cli/action_registry.py +1047 -0
  6. algo_cli/agent_blocks.py +550 -0
  7. algo_cli/agent_pipeline.py +1457 -0
  8. algo_cli/agent_threads.py +308 -0
  9. algo_cli/animations.py +316 -0
  10. algo_cli/cache_admission.py +209 -0
  11. algo_cli/capability_mask.py +66 -0
  12. algo_cli/chat_protocol.py +116 -0
  13. algo_cli/chatgpt_auth.py +510 -0
  14. algo_cli/chatgpt_client.py +657 -0
  15. algo_cli/code_rag.py +479 -0
  16. algo_cli/config.py +651 -0
  17. algo_cli/context_budget.py +679 -0
  18. algo_cli/credential_helpers.py +315 -0
  19. algo_cli/deliberation.py +29 -0
  20. algo_cli/display.py +1470 -0
  21. algo_cli/evals/__init__.py +21 -0
  22. algo_cli/evals/algorithm_effectiveness.py +560 -0
  23. algo_cli/evals/competitive_harness_rating.py +702 -0
  24. algo_cli/evals/cot_quality.py +220 -0
  25. algo_cli/evals/harness_retrieval_benchmark.py +401 -0
  26. algo_cli/evals/performance_regression.py +136 -0
  27. algo_cli/evals/scorecard_grading.py +308 -0
  28. algo_cli/evals/session_distribution.py +84 -0
  29. algo_cli/execution_guardrails.py +806 -0
  30. algo_cli/extensions_manifest.py +84 -0
  31. algo_cli/git_evidence.py +227 -0
  32. algo_cli/google_workspace.py +407 -0
  33. algo_cli/google_workspace_auth.py +523 -0
  34. algo_cli/harness.py +2587 -0
  35. algo_cli/identity.py +557 -0
  36. algo_cli/index_compute_lab.py +228 -0
  37. algo_cli/inference_harness.py +70 -0
  38. algo_cli/intelligence/__init__.py +1103 -0
  39. algo_cli/intelligence/acrobat_config.py +307 -0
  40. algo_cli/intelligence/acrobat_manifests.py +338 -0
  41. algo_cli/intelligence/acrobat_models.py +195 -0
  42. algo_cli/intelligence/acrobat_pipeline.py +295 -0
  43. algo_cli/intelligence/acrobat_runtime.py +302 -0
  44. algo_cli/intelligence/acrobat_security.py +261 -0
  45. algo_cli/intelligence/acrobat_workflows.py +226 -0
  46. algo_cli/intelligence/actionability.py +165 -0
  47. algo_cli/intelligence/adversarial_audit.py +136 -0
  48. algo_cli/intelligence/agent_arena.py +92 -0
  49. algo_cli/intelligence/agent_benchmark.py +236 -0
  50. algo_cli/intelligence/agent_runtime.py +171 -0
  51. algo_cli/intelligence/agents_as_tools.py +70 -0
  52. algo_cli/intelligence/artifact_binding.py +80 -0
  53. algo_cli/intelligence/autonomous_engineer.py +1976 -0
  54. algo_cli/intelligence/backpressure.py +99 -0
  55. algo_cli/intelligence/bloom_filter.py +186 -0
  56. algo_cli/intelligence/bonferroni.py +66 -0
  57. algo_cli/intelligence/boundary_compaction.py +98 -0
  58. algo_cli/intelligence/catalog_verifier.py +172 -0
  59. algo_cli/intelligence/cavecrew.py +118 -0
  60. algo_cli/intelligence/changelog.py +176 -0
  61. algo_cli/intelligence/checkpoint_resume.py +92 -0
  62. algo_cli/intelligence/circuit_breaker.py +88 -0
  63. algo_cli/intelligence/clarification_gate.py +101 -0
  64. algo_cli/intelligence/code_graph.py +180 -0
  65. algo_cli/intelligence/coderank.py +97 -0
  66. algo_cli/intelligence/consistent_hash.py +150 -0
  67. algo_cli/intelligence/consortium_synthesis.py +139 -0
  68. algo_cli/intelligence/construction/__init__.py +241 -0
  69. algo_cli/intelligence/construction/common.py +273 -0
  70. algo_cli/intelligence/construction/documents.py +496 -0
  71. algo_cli/intelligence/construction/labor_units.py +1395 -0
  72. algo_cli/intelligence/construction/payments.py +470 -0
  73. algo_cli/intelligence/construction/risk.py +784 -0
  74. algo_cli/intelligence/content_extractor.py +132 -0
  75. algo_cli/intelligence/context_adaptive.py +102 -0
  76. algo_cli/intelligence/context_ops.py +95 -0
  77. algo_cli/intelligence/count_min.py +145 -0
  78. algo_cli/intelligence/cow_state.py +103 -0
  79. algo_cli/intelligence/critic_loop.py +119 -0
  80. algo_cli/intelligence/cross_source.py +113 -0
  81. algo_cli/intelligence/daemon_mode.py +99 -0
  82. algo_cli/intelligence/dag_orchestration.py +151 -0
  83. algo_cli/intelligence/deep_research.py +155 -0
  84. algo_cli/intelligence/degenerate_detector.py +78 -0
  85. algo_cli/intelligence/delta_report.py +92 -0
  86. algo_cli/intelligence/discovery_event_log.py +92 -0
  87. algo_cli/intelligence/document_ingest.py +298 -0
  88. algo_cli/intelligence/dual_layer_validate.py +151 -0
  89. algo_cli/intelligence/echo_fidelity.py +73 -0
  90. algo_cli/intelligence/ema_tuning.py +104 -0
  91. algo_cli/intelligence/event_log.py +92 -0
  92. algo_cli/intelligence/evidence_graph.py +114 -0
  93. algo_cli/intelligence/extension_host.py +162 -0
  94. algo_cli/intelligence/extension_manifest.py +115 -0
  95. algo_cli/intelligence/falsification_suite.py +178 -0
  96. algo_cli/intelligence/finance/__init__.py +169 -0
  97. algo_cli/intelligence/finance/anomalies.py +135 -0
  98. algo_cli/intelligence/finance/ap_ar.py +351 -0
  99. algo_cli/intelligence/finance/cash.py +162 -0
  100. algo_cli/intelligence/finance/close.py +332 -0
  101. algo_cli/intelligence/finance/common.py +244 -0
  102. algo_cli/intelligence/finance/construction.py +135 -0
  103. algo_cli/intelligence/finance/controls.py +172 -0
  104. algo_cli/intelligence/finance/evidence.py +119 -0
  105. algo_cli/intelligence/finance/exceptions.py +157 -0
  106. algo_cli/intelligence/finance/reconciliations.py +254 -0
  107. algo_cli/intelligence/finance/revenue.py +109 -0
  108. algo_cli/intelligence/finance/tax.py +74 -0
  109. algo_cli/intelligence/finance/workpapers.py +111 -0
  110. algo_cli/intelligence/finding_record.py +120 -0
  111. algo_cli/intelligence/flow_dag.py +267 -0
  112. algo_cli/intelligence/gatherer.py +223 -0
  113. algo_cli/intelligence/golden_master.py +98 -0
  114. algo_cli/intelligence/graph_rag.py +195 -0
  115. algo_cli/intelligence/group_chat.py +143 -0
  116. algo_cli/intelligence/hash_dedup.py +145 -0
  117. algo_cli/intelligence/hyperloglog.py +128 -0
  118. algo_cli/intelligence/incremental_index.py +316 -0
  119. algo_cli/intelligence/index_store.py +16 -0
  120. algo_cli/intelligence/iteration_plan.py +133 -0
  121. algo_cli/intelligence/kernel_plugins.py +167 -0
  122. algo_cli/intelligence/lesson_catalog.py +135 -0
  123. algo_cli/intelligence/llm_fallback.py +169 -0
  124. algo_cli/intelligence/log2_histogram.py +267 -0
  125. algo_cli/intelligence/lsp_integration.py +147 -0
  126. algo_cli/intelligence/memory_evolution.py +117 -0
  127. algo_cli/intelligence/minhash_lsh.py +182 -0
  128. algo_cli/intelligence/multi_model_score.py +174 -0
  129. algo_cli/intelligence/multi_tier_grade.py +211 -0
  130. algo_cli/intelligence/negative_controls.py +113 -0
  131. algo_cli/intelligence/numeric_clamp.py +63 -0
  132. algo_cli/intelligence/occ_editor.py +66 -0
  133. algo_cli/intelligence/output_normalize.py +112 -0
  134. algo_cli/intelligence/parallel_delegation.py +98 -0
  135. algo_cli/intelligence/parallel_fanout.py +104 -0
  136. algo_cli/intelligence/permission_modes.py +105 -0
  137. algo_cli/intelligence/pre_push_gate.py +68 -0
  138. algo_cli/intelligence/prefetch.py +171 -0
  139. algo_cli/intelligence/process_framework.py +217 -0
  140. algo_cli/intelligence/project_graph.py +387 -0
  141. algo_cli/intelligence/query_expansion.py +146 -0
  142. algo_cli/intelligence/ralph_loop.py +117 -0
  143. algo_cli/intelligence/rate_limiter.py +153 -0
  144. algo_cli/intelligence/refactor_transaction.py +94 -0
  145. algo_cli/intelligence/research_workspace.py +108 -0
  146. algo_cli/intelligence/retraction_ledger.py +72 -0
  147. algo_cli/intelligence/saga_pattern.py +88 -0
  148. algo_cli/intelligence/session_fork.py +100 -0
  149. algo_cli/intelligence/shadow_editor.py +67 -0
  150. algo_cli/intelligence/shell_session.py +213 -0
  151. algo_cli/intelligence/source_registry.py +143 -0
  152. algo_cli/intelligence/spawn_scales.py +99 -0
  153. algo_cli/intelligence/stat_stability.py +104 -0
  154. algo_cli/intelligence/structural_validator.py +148 -0
  155. algo_cli/intelligence/subagent_spawner.py +111 -0
  156. algo_cli/intelligence/symmetric_verify.py +70 -0
  157. algo_cli/intelligence/task_classifier.py +129 -0
  158. algo_cli/intelligence/team_execution.py +122 -0
  159. algo_cli/intelligence/tiered_access.py +121 -0
  160. algo_cli/intelligence/utility_registry.py +159 -0
  161. algo_cli/intuition_engine.py +560 -0
  162. algo_cli/intuition_injector.py +82 -0
  163. algo_cli/kernels/__init__.py +5 -0
  164. algo_cli/kernels/manifest.py +763 -0
  165. algo_cli/main.py +3903 -0
  166. algo_cli/memory_candidates.py +541 -0
  167. algo_cli/memory_echo_veil.py +394 -0
  168. algo_cli/memory_runtime.py +112 -0
  169. algo_cli/model_info.py +548 -0
  170. algo_cli/model_profile.py +160 -0
  171. algo_cli/model_routing.py +74 -0
  172. algo_cli/oneshot.py +331 -0
  173. algo_cli/perf_telemetry.py +389 -0
  174. algo_cli/plugins.py +245 -0
  175. algo_cli/private_event_store.py +654 -0
  176. algo_cli/quantization/__init__.py +24 -0
  177. algo_cli/quantization/lloyd_max.py +98 -0
  178. algo_cli/quantization/turbo_quant.py +308 -0
  179. algo_cli/reasoning/__init__.py +46 -0
  180. algo_cli/reasoning/combinatorial.py +356 -0
  181. algo_cli/reasoning/graph_of_thought.py +297 -0
  182. algo_cli/reasoning/mcts.py +220 -0
  183. algo_cli/reasoning/neuro_symbolic.py +250 -0
  184. algo_cli/reasoning/react.py +246 -0
  185. algo_cli/reasoning/reflexion.py +225 -0
  186. algo_cli/reasoning/tree_of_thought.py +241 -0
  187. algo_cli/reasoning_bridge.py +150 -0
  188. algo_cli/reconciliation.py +284 -0
  189. algo_cli/reflex.py +385 -0
  190. algo_cli/resources/docs/ALGO.md +13958 -0
  191. algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
  192. algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
  193. algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
  194. algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
  195. algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
  196. algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
  197. algo_cli/resources/docs/main-split-map.md +35 -0
  198. algo_cli/resources/docs/privacy-and-context.md +48 -0
  199. algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
  200. algo_cli/resources/skills/README.md +26 -0
  201. algo_cli/resources/skills/algo-cli.md +59 -0
  202. algo_cli/resources/skills/edit-file-precision.md +49 -0
  203. algo_cli/resources/skills/harness-search-first.md +47 -0
  204. algo_cli/resources/skills/memory-recall-ritual.md +51 -0
  205. algo_cli/resources/skills/qol-algorithms.md +224 -0
  206. algo_cli/resources/skills/smart-error-recovery.md +56 -0
  207. algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
  208. algo_cli/retrieval_algorithms.py +127 -0
  209. algo_cli/runtime_qos.py +236 -0
  210. algo_cli/runtime_services.py +320 -0
  211. algo_cli/session_commands.py +95 -0
  212. algo_cli/session_mode.py +113 -0
  213. algo_cli/skills.py +430 -0
  214. algo_cli/slash_dispatch.py +1265 -0
  215. algo_cli/small_context.py +206 -0
  216. algo_cli/spawn_budget.py +89 -0
  217. algo_cli/task_ledger.py +84 -0
  218. algo_cli/task_router.py +197 -0
  219. algo_cli/tool_context.py +94 -0
  220. algo_cli/tool_contract.py +99 -0
  221. algo_cli/tool_policy.py +357 -0
  222. algo_cli/tool_runtime.py +647 -0
  223. algo_cli/tools.py +3056 -0
  224. algo_cli/url_scheme.py +174 -0
  225. algo_cli/verify.py +154 -0
  226. algo_cli/version_manifest.py +178 -0
  227. algo_cli/vision_screenshot_verify.py +76 -0
  228. algo_cli/workspace_resolver.py +68 -0
  229. algo_cli/x_account.py +209 -0
  230. algo_cli/xai_auth.py +374 -0
  231. algo_cli/xai_client.py +600 -0
  232. algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
  233. algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
  234. algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
  235. algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
  236. algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
  237. 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"