lambda-erp 0.1.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 (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,704 @@
1
+ """
2
+ Historical business simulator.
3
+
4
+ Walks business days between two dates (skipping weekends and country holidays)
5
+ and generates a realistic stream of documents:
6
+
7
+ Quotation -> (80% Lost | 20% Sales Order) -> Delivery Note(s) -> Sales Invoice -> Payment Entry
8
+ (Reorder threshold) -> Purchase Order -> Purchase Receipt -> Purchase Invoice -> Payment Entry
9
+
10
+ Flow timing is randomized within business-day windows, with monthly seasonality
11
+ and year-over-year growth applied to the quotation arrival rate. A seeded RNG
12
+ makes runs reproducible.
13
+
14
+ Partial flows:
15
+ - 10% of sales orders split across two Delivery Notes
16
+ - 15% of sales invoices paid in two Payment Entries
17
+ - 5% of sales invoices remain outstanding at end of run
18
+ """
19
+
20
+ import math
21
+ import random
22
+ from collections import defaultdict
23
+ from datetime import date, timedelta
24
+
25
+ import holidays as pyholidays
26
+
27
+ from lambda_erp.database import get_db
28
+ from lambda_erp.utils import _dict, flt, getdate, add_days
29
+ from lambda_erp.stock.stock_ledger import get_stock_balance
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Tunables
34
+ # ---------------------------------------------------------------------------
35
+
36
+ MONTH_SEASONALITY = {
37
+ 1: 0.85, 2: 0.90, 3: 1.00,
38
+ 4: 1.05, 5: 1.10, 6: 0.85,
39
+ 7: 0.70, 8: 0.75, 9: 1.05,
40
+ 10: 1.15, 11: 1.20, 12: 1.25,
41
+ }
42
+
43
+ ANNUAL_GROWTH = 0.15
44
+ BASE_QUOTES_PER_DAY = 2.5
45
+
46
+ QUOTE_CONVERSION_RATE = 0.20
47
+ DAYS_QUOTE_TO_DECISION = (3, 15)
48
+ DAYS_SO_TO_DELIVERY = (2, 10)
49
+ DAYS_DN_TO_INVOICE = (0, 3)
50
+ DAYS_PARTIAL_DELIVERY_GAP = (3, 10)
51
+ DAYS_INVOICE_NET = 30
52
+ DAYS_PAYMENT_VARIANCE = (-5, 20)
53
+ DAYS_SECOND_PAYMENT_GAP = (5, 30)
54
+
55
+ DAYS_PO_TO_RECEIPT = (3, 10)
56
+ DAYS_PR_TO_INVOICE = (0, 2)
57
+ DAYS_PI_NET = 30
58
+ DAYS_PI_PAYMENT_VARIANCE = (0, 15)
59
+
60
+ PARTIAL_DELIVERY_PCT = 0.10
61
+ PARTIAL_PAYMENT_PCT = 0.15
62
+ OUTSTANDING_INVOICE_PCT = 0.05
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Master data (same universe as seed_demo, plus reorder parameters)
67
+ # ---------------------------------------------------------------------------
68
+
69
+ CUSTOMERS = [
70
+ dict(name="CUST-001", customer_name="Riverside Manufacturing", customer_group="Commercial",
71
+ email="orders@riverside-mfg.com", phone="+1-555-0101", address="42 River Road",
72
+ city="Portland", zip_code="97201", country="US", tax_id="US-RM-78234"),
73
+ dict(name="CUST-002", customer_name="Summit Logistics", customer_group="Commercial",
74
+ email="procurement@summitlog.com", phone="+1-555-0102", address="880 Summit Ave",
75
+ city="Denver", zip_code="80202", country="US", tax_id="US-SL-44521"),
76
+ dict(name="CUST-003", customer_name="Crescent Healthcare", customer_group="Premium",
77
+ email="supply@crescenthc.org", phone="+1-555-0103", address="15 Crescent Blvd",
78
+ city="Austin", zip_code="78701", country="US", tax_id="US-CH-91037"),
79
+ dict(name="CUST-004", customer_name="Horizon Energy Solutions", customer_group="Commercial",
80
+ email="purchasing@horizonenergy.com", phone="+1-555-0104", address="3200 Energy Pkwy",
81
+ city="Houston", zip_code="77002", country="US"),
82
+ dict(name="CUST-005", customer_name="Lakeside Construction", customer_group="Commercial",
83
+ email="info@lakesideconstruction.com", phone="+1-555-0105", address="77 Lakeview Dr",
84
+ city="Chicago", zip_code="60601", country="US"),
85
+ dict(name="CUST-006", customer_name="Pine Valley Schools", customer_group="Government",
86
+ email="facilities@pinevalleysd.edu", phone="+1-555-0106", address="1 School Lane",
87
+ city="Sacramento", zip_code="95814", country="US"),
88
+ dict(name="CUST-007", customer_name="Redstone Automotive", customer_group="Premium",
89
+ email="parts@redstoneauto.com", phone="+1-555-0107", address="500 Motor Way",
90
+ city="Detroit", zip_code="48201", country="US"),
91
+ dict(name="CUST-008", customer_name="Clearwater Foods", customer_group="Commercial",
92
+ email="ops@clearwaterfoods.com", phone="+1-555-0108", address="22 Harbor St",
93
+ city="Seattle", zip_code="98101", country="US"),
94
+ dict(name="CUST-009", customer_name="Bridgeport Electronics", customer_group="Commercial",
95
+ email="sourcing@bridgeportelec.com", phone="+1-555-0109", address="150 Circuit Ave",
96
+ city="San Jose", zip_code="95110", country="US"),
97
+ dict(name="CUST-010", customer_name="Granite Peak Mining", customer_group="Premium",
98
+ email="supply@granitepeak.com", phone="+1-555-0110", address="9 Mine Rd",
99
+ city="Salt Lake City", zip_code="84101", country="US"),
100
+ ]
101
+
102
+ SUPPLIERS = [
103
+ dict(name="SUPP-001", supplier_name="Atlas Supply Co",
104
+ email="sales@atlassupply.com", phone="+1-555-0201", address="100 Industrial Blvd",
105
+ city="Cleveland", zip_code="44114", country="US", tax_id="US-AS-55123"),
106
+ dict(name="SUPP-002", supplier_name="Northern Materials",
107
+ email="orders@northernmat.com", phone="+1-555-0202", address="45 Nordic Way",
108
+ city="Minneapolis", zip_code="55401", country="US", tax_id="US-NM-62890"),
109
+ dict(name="SUPP-003", supplier_name="Keystone Fasteners",
110
+ email="wholesale@keystonefast.com", phone="+1-555-0203", address="88 Bolt St",
111
+ city="Pittsburgh", zip_code="15222", country="US"),
112
+ dict(name="SUPP-004", supplier_name="Delta Fluid Systems",
113
+ email="sales@deltafluid.com", phone="+1-555-0204", address="200 Hydraulic Dr",
114
+ city="Birmingham", zip_code="35203", country="US"),
115
+ dict(name="SUPP-005", supplier_name="Ironclad Metals",
116
+ email="trade@ironcladmetals.com", phone="+1-555-0205", address="12 Forge Lane",
117
+ city="Gary", zip_code="46402", country="US"),
118
+ ]
119
+
120
+ # (code, name, uom, sell_price, cost_factor, reorder_level, reorder_qty)
121
+ STOCK_ITEMS = [
122
+ ("ITEM-001", "Bolt Pack M8", "Nos", 100, 0.60, 80, 400),
123
+ ("ITEM-002", "Gasket Set K2", "Nos", 250, 0.65, 60, 250),
124
+ ("ITEM-003", "Bearing Assembly Pro", "Nos", 500, 0.62, 40, 150),
125
+ ("ITEM-004", "Steel Flange DN50", "Nos", 85, 0.58, 80, 350),
126
+ ("ITEM-005", "Copper Tube 15mm", "Meter", 12, 0.55, 200, 1000),
127
+ ("ITEM-006", "Hydraulic Hose 3/4in", "Meter", 35, 0.60, 120, 500),
128
+ ("ITEM-007", "Air Filter Cartridge", "Nos", 45, 0.58, 100, 400),
129
+ ("ITEM-008", "Weld Rod E7018 5kg", "Box", 60, 0.62, 60, 200),
130
+ ("ITEM-009", "Safety Valve DN25", "Nos", 320, 0.64, 30, 120),
131
+ ("ITEM-010", "O-Ring Kit Imperial", "Set", 28, 0.55, 100, 400),
132
+ ("ITEM-011", "Stainless Sheet 2mm", "Sheet", 190, 0.63, 40, 150),
133
+ ("ITEM-012", "Cable Tray 300mm", "Meter", 42, 0.60, 80, 300),
134
+ ("ITEM-013", "Pressure Gauge 0-10bar", "Nos", 75, 0.58, 60, 200),
135
+ ("ITEM-014", "Thermal Insulation Wrap", "Roll", 110, 0.62, 40, 150),
136
+ ("ITEM-015", "Anchor Bolt Set M12", "Set", 55, 0.58, 80, 300),
137
+ ]
138
+
139
+ # (code, name, uom, sell_price)
140
+ SERVICE_ITEMS = [
141
+ ("SVC-001", "Engineering Consultation", "Hour", 150),
142
+ ("SVC-002", "Field Setup Service", "Hour", 200),
143
+ ("SVC-003", "Calibration Service", "Hour", 175),
144
+ ("SVC-004", "Welding Inspection", "Hour", 120),
145
+ ("SVC-005", "Project Management", "Hour", 250),
146
+ ]
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Simulator
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ class HistoricalSimulator:
155
+ def __init__(self, company, start, end, seed=42, intensity=1.0, country="US", log=True):
156
+ self.company = company
157
+ self.abbr = company[:4].upper()
158
+ self.warehouse = f"Main Warehouse - {self.abbr}"
159
+ self.start = getdate(start)
160
+ self.end = getdate(end)
161
+ if self.end < self.start:
162
+ raise ValueError("end_date must be >= start_date")
163
+
164
+ self.base_year = self.start.year
165
+ self.intensity = intensity
166
+ self.rng = random.Random(seed)
167
+ self.holidays = pyholidays.country_holidays(country)
168
+ # Log progress to stdout by default — a fresh container boot runs
169
+ # ~3 minutes of simulation and `docker compose up` users otherwise
170
+ # see no sign of life. Flip off for test suites that want silence.
171
+ self.log_enabled = bool(log)
172
+
173
+ self.events: dict[date, list] = defaultdict(list)
174
+ self.reorder_info: dict[str, dict] = {}
175
+ self.stats: dict[str, int] = defaultdict(int)
176
+
177
+ self._customer_names: list[str] = []
178
+ self._stock_item_codes: list[str] = []
179
+ self._service_item_codes: list[str] = []
180
+ self._item_prices: dict[str, float] = {}
181
+
182
+ def _log(self, msg: str) -> None:
183
+ if self.log_enabled:
184
+ print(f"[sim] {msg}", flush=True)
185
+
186
+ # ----- public -----
187
+
188
+ def run(self, simulate_activity: bool = True):
189
+ """Seed master data, and optionally simulate transactional activity.
190
+
191
+ With simulate_activity=False this behaves as a fast master-data seeder
192
+ (customers, suppliers, items, warehouse only) — useful for new admins
193
+ who want an empty ERP to start entering real data. With True (default)
194
+ it also seeds opening stock and walks every business day in the range
195
+ to produce ~3 years of simulated transactions.
196
+ """
197
+ import time
198
+
199
+ t0 = time.monotonic()
200
+ self._log(f"seeding masters for {self.company}")
201
+ self._seed_masters()
202
+
203
+ if not simulate_activity:
204
+ self._log(f"masters seeded in {time.monotonic() - t0:.1f}s (activity skipped)")
205
+ return dict(self.stats)
206
+
207
+ self._log("seeding opening stock")
208
+ self._seed_opening_stock()
209
+
210
+ total_days = (self.end - self.start).days + 1
211
+ self._log(f"simulating {total_days} days ({self.start} -> {self.end})")
212
+ last_logged_month = (self.start.year, self.start.month - 1)
213
+ phase_start = time.monotonic()
214
+
215
+ d = self.start
216
+ while d <= self.end:
217
+ if self._is_business_day(d):
218
+ self._run_scheduled_events(d)
219
+ self._maybe_generate_quotations(d)
220
+ self._check_reorder_points(d)
221
+
222
+ # One progress line per calendar month of simulated time so a
223
+ # `docker compose up` watcher sees steady progress over ~36
224
+ # lines for a 3-year run.
225
+ if (d.year, d.month) != last_logged_month:
226
+ last_logged_month = (d.year, d.month)
227
+ pct = int(100 * ((d - self.start).days + 1) / total_days)
228
+ self._log(
229
+ f" {d:%Y-%m} [{pct:3d}%] "
230
+ f"qtn={self.stats.get('quotations', 0)} "
231
+ f"sinv={self.stats.get('sales_invoices', 0)} "
232
+ f"pinv={self.stats.get('purchase_invoices', 0)} "
233
+ f"pay={self.stats.get('payments', 0)}"
234
+ )
235
+ d += timedelta(days=1)
236
+
237
+ self._log(
238
+ f"done in {time.monotonic() - phase_start:.1f}s | "
239
+ f"qtn={self.stats.get('quotations', 0)} "
240
+ f"sinv={self.stats.get('sales_invoices', 0)} "
241
+ f"pinv={self.stats.get('purchase_invoices', 0)} "
242
+ f"pay={self.stats.get('payments', 0)}"
243
+ )
244
+ return dict(self.stats)
245
+
246
+ # ----- calendar helpers -----
247
+
248
+ def _is_business_day(self, d: date) -> bool:
249
+ return d.weekday() < 5 and d not in self.holidays
250
+
251
+ def _add_business_days(self, d: date, n: int) -> date:
252
+ cur = d
253
+ while n > 0:
254
+ cur += timedelta(days=1)
255
+ if self._is_business_day(cur):
256
+ n -= 1
257
+ return cur
258
+
259
+ def _next_business_day(self, d: date) -> date:
260
+ while not self._is_business_day(d):
261
+ d += timedelta(days=1)
262
+ return d
263
+
264
+ # ----- event queue -----
265
+
266
+ def _schedule(self, on_day: date, callback):
267
+ if on_day > self.end:
268
+ return
269
+ on_day = self._next_business_day(on_day)
270
+ if on_day > self.end:
271
+ return
272
+ self.events[on_day].append(callback)
273
+
274
+ def _run_scheduled_events(self, day: date):
275
+ events = self.events.pop(day, [])
276
+ for evt in events:
277
+ try:
278
+ evt(day)
279
+ except Exception as e:
280
+ self.stats["event_errors"] += 1
281
+ print(f" [sim] {day} event failed: {type(e).__name__}: {e}")
282
+
283
+ # ----- master data -----
284
+
285
+ def _seed_masters(self):
286
+ db = get_db()
287
+
288
+ for c in CUSTOMERS:
289
+ if not db.exists("Customer", c["name"]):
290
+ db.insert("Customer", _dict(**c))
291
+
292
+ for s in SUPPLIERS:
293
+ if not db.exists("Supplier", s["name"]):
294
+ db.insert("Supplier", _dict(**s))
295
+
296
+ for code, item_name, uom, price, *_ in STOCK_ITEMS:
297
+ if not db.exists("Item", code):
298
+ db.insert("Item", _dict(
299
+ name=code, item_name=item_name, stock_uom=uom,
300
+ standard_rate=price, is_stock_item=1,
301
+ ))
302
+
303
+ for code, item_name, uom, price in SERVICE_ITEMS:
304
+ if not db.exists("Item", code):
305
+ db.insert("Item", _dict(
306
+ name=code, item_name=item_name, stock_uom=uom,
307
+ standard_rate=price, is_stock_item=0,
308
+ ))
309
+
310
+ if not db.exists("Warehouse", self.warehouse):
311
+ db.insert("Warehouse", _dict(
312
+ name=self.warehouse, warehouse_name="Main Warehouse",
313
+ company=self.company,
314
+ ))
315
+
316
+ # Demo exchange rates (base USD) so foreign-currency documents resolve a
317
+ # conversion_rate automatically. Dated far in the past so the lookup
318
+ # carries forward to any transaction date.
319
+ for frm, rate in (("EUR", 1.10), ("CHF", 1.12), ("GBP", 1.27)):
320
+ xname = f"{frm}-USD-2020-01-01"
321
+ if not db.exists("Currency Exchange", xname):
322
+ db.insert("Currency Exchange", _dict(
323
+ name=xname, date="2020-01-01",
324
+ from_currency=frm, to_currency="USD", exchange_rate=rate,
325
+ ))
326
+
327
+ supplier_names = [s["name"] for s in SUPPLIERS]
328
+ for code, _n, _u, price, cost_factor, reorder_level, reorder_qty in STOCK_ITEMS:
329
+ self.reorder_info[code] = {
330
+ "level": reorder_level,
331
+ "qty": reorder_qty,
332
+ "rate": flt(price * cost_factor, 2),
333
+ "supplier": self.rng.choice(supplier_names),
334
+ "open_po": 0,
335
+ }
336
+
337
+ self._customer_names = [c["name"] for c in CUSTOMERS]
338
+ self._stock_item_codes = [i[0] for i in STOCK_ITEMS]
339
+ self._service_item_codes = [i[0] for i in SERVICE_ITEMS]
340
+ self._item_prices = {i[0]: i[3] for i in STOCK_ITEMS}
341
+ self._item_prices.update({i[0]: i[3] for i in SERVICE_ITEMS})
342
+
343
+ def _seed_opening_stock(self):
344
+ from lambda_erp.stock.stock_entry import StockEntry
345
+
346
+ items = []
347
+ for code, info in self.reorder_info.items():
348
+ # Seed ~2x reorder_level: comfortable buffer while still triggering
349
+ # restocks within the first few months of simulated activity.
350
+ qty = int(info["level"] * self.rng.uniform(2.0, 3.0))
351
+ items.append(_dict(item_code=code, qty=qty, basic_rate=info["rate"]))
352
+
353
+ se = StockEntry(
354
+ stock_entry_type="Opening Stock",
355
+ posting_date=self.start.isoformat(),
356
+ company=self.company,
357
+ to_warehouse=self.warehouse,
358
+ items=items,
359
+ )
360
+ se.save()
361
+ se.submit()
362
+ self.stats["opening_stock_entries"] += 1
363
+
364
+ # ----- quotation generation -----
365
+
366
+ def _daily_intensity(self, day: date) -> float:
367
+ month_mult = MONTH_SEASONALITY[day.month]
368
+ year_offset = day.year - self.base_year
369
+ growth = (1.0 + ANNUAL_GROWTH) ** year_offset
370
+ return BASE_QUOTES_PER_DAY * self.intensity * month_mult * growth
371
+
372
+ def _poisson(self, lam: float) -> int:
373
+ """Knuth's algorithm, seeded via self.rng."""
374
+ if lam <= 0:
375
+ return 0
376
+ L = math.exp(-lam)
377
+ k = 0
378
+ p = 1.0
379
+ while p > L:
380
+ k += 1
381
+ p *= self.rng.random()
382
+ return k - 1
383
+
384
+ def _maybe_generate_quotations(self, day: date):
385
+ count = self._poisson(self._daily_intensity(day))
386
+ for _ in range(count):
387
+ self._create_quotation(day)
388
+
389
+ def _create_quotation(self, day: date):
390
+ from lambda_erp.selling.quotation import Quotation
391
+
392
+ customer = self.rng.choice(self._customer_names)
393
+
394
+ n_items = self.rng.randint(1, 4)
395
+ picks: list[str] = []
396
+ for _ in range(n_items):
397
+ pool = self._stock_item_codes if self.rng.random() < 0.75 else self._service_item_codes
398
+ picks.append(self.rng.choice(pool))
399
+ picks = list(dict.fromkeys(picks))
400
+
401
+ items = []
402
+ for code in picks:
403
+ base = self._item_prices[code]
404
+ rate = flt(base * self.rng.uniform(0.9, 1.1), 2)
405
+ qty = self.rng.randint(1, 20)
406
+ items.append(_dict(item_code=code, qty=qty, rate=rate))
407
+
408
+ q = Quotation(
409
+ customer=customer,
410
+ company=self.company,
411
+ transaction_date=day.isoformat(),
412
+ items=items,
413
+ taxes=[_dict(
414
+ charge_type="On Net Total",
415
+ account_head=f"Tax Payable - {self.abbr}",
416
+ description="Sales Tax 10%",
417
+ rate=10, idx=1,
418
+ )],
419
+ )
420
+ q.save()
421
+ q.submit()
422
+ self.stats["quotations"] += 1
423
+
424
+ decide_day = self._add_business_days(day, self.rng.randint(*DAYS_QUOTE_TO_DECISION))
425
+ qname = q.name
426
+ if self.rng.random() < QUOTE_CONVERSION_RATE:
427
+ self._schedule(decide_day, lambda d, n=qname: self._convert_quotation(d, n))
428
+ else:
429
+ self._schedule(decide_day, lambda d, n=qname: self._lose_quotation(d, n))
430
+
431
+ def _lose_quotation(self, day: date, qname: str):
432
+ db = get_db()
433
+ db.set_value("Quotation", qname, "status", "Lost")
434
+ db.conn.commit()
435
+ self.stats["quotations_lost"] += 1
436
+
437
+ def _convert_quotation(self, day: date, qname: str):
438
+ from lambda_erp.selling.quotation import make_sales_order
439
+
440
+ so = make_sales_order(qname)
441
+ so.transaction_date = day.isoformat()
442
+ delivery_date = self._add_business_days(day, self.rng.randint(*DAYS_SO_TO_DELIVERY))
443
+ so.delivery_date = delivery_date.isoformat()
444
+
445
+ for item in so.get("items"):
446
+ if item.get("item_code") in self.reorder_info and not item.get("warehouse"):
447
+ item["warehouse"] = self.warehouse
448
+
449
+ so.save()
450
+ so.submit()
451
+ self.stats["sales_orders"] += 1
452
+
453
+ soname = so.name
454
+ first_of_two = self.rng.random() < PARTIAL_DELIVERY_PCT
455
+ self._schedule(
456
+ delivery_date,
457
+ lambda d, n=soname, f=first_of_two: self._do_delivery(d, n, first_of_two=f),
458
+ )
459
+
460
+ def _do_delivery(self, day: date, soname: str, first_of_two: bool = False):
461
+ from lambda_erp.stock.delivery_note import make_delivery_note
462
+
463
+ dn = make_delivery_note(soname)
464
+ if not dn.get("items"):
465
+ return
466
+
467
+ dn.posting_date = day.isoformat()
468
+ for item in dn.get("items"):
469
+ if not item.get("warehouse") and item.get("item_code") in self.reorder_info:
470
+ item["warehouse"] = self.warehouse
471
+
472
+ if first_of_two:
473
+ for item in dn.get("items"):
474
+ orig = flt(item["qty"])
475
+ if orig > 1:
476
+ item["qty"] = max(1, int(orig / 2))
477
+
478
+ # Availability check against current stock (skip items without warehouse — services)
479
+ insufficient = False
480
+ for item in list(dn.get("items")):
481
+ wh = item.get("warehouse")
482
+ if not wh:
483
+ continue
484
+ bal = get_stock_balance(item["item_code"], wh)
485
+ avail = flt(bal.actual_qty) if bal else 0
486
+ want = flt(item["qty"])
487
+ if avail <= 0:
488
+ dn.get("items").remove(item)
489
+ insufficient = True
490
+ elif avail < want:
491
+ item["qty"] = avail
492
+ insufficient = True
493
+
494
+ if not dn.get("items"):
495
+ # nothing to ship right now — retry in a few days
496
+ retry_day = self._add_business_days(day, self.rng.randint(2, 5))
497
+ self._schedule(
498
+ retry_day,
499
+ lambda d, n=soname, f=first_of_two: self._do_delivery(d, n, first_of_two=f),
500
+ )
501
+ self.stats["deliveries_deferred"] += 1
502
+ return
503
+
504
+ dn.save()
505
+ dn.submit()
506
+ self.stats["delivery_notes"] += 1
507
+
508
+ if first_of_two:
509
+ gap = self.rng.randint(*DAYS_PARTIAL_DELIVERY_GAP)
510
+ second_day = self._add_business_days(day, gap)
511
+ self._schedule(
512
+ second_day,
513
+ lambda d, n=soname: self._do_delivery(d, n, first_of_two=False),
514
+ )
515
+ return
516
+
517
+ invoice_day = self._add_business_days(day, self.rng.randint(*DAYS_DN_TO_INVOICE))
518
+ self._schedule(invoice_day, lambda d, n=soname: self._do_invoice(d, n))
519
+
520
+ def _do_invoice(self, day: date, soname: str):
521
+ from lambda_erp.selling.sales_order import make_sales_invoice
522
+
523
+ inv = make_sales_invoice(soname)
524
+ inv.posting_date = day.isoformat()
525
+ inv.due_date = add_days(day, DAYS_INVOICE_NET).isoformat()
526
+ inv.save()
527
+ inv.submit()
528
+ self.stats["sales_invoices"] += 1
529
+
530
+ invname = inv.name
531
+ grand_total = flt(inv.grand_total)
532
+
533
+ r = self.rng.random()
534
+ if r < OUTSTANDING_INVOICE_PCT:
535
+ self.stats["invoices_left_outstanding"] += 1
536
+ return
537
+
538
+ due = getdate(inv.due_date)
539
+ pay_day = due + timedelta(days=self.rng.randint(*DAYS_PAYMENT_VARIANCE))
540
+ pay_day = self._next_business_day(pay_day)
541
+
542
+ if r < OUTSTANDING_INVOICE_PCT + PARTIAL_PAYMENT_PCT:
543
+ half = flt(grand_total / 2, 2)
544
+ self._schedule(
545
+ pay_day,
546
+ lambda d, n=invname, a=half: self._do_payment(d, n, amount=a),
547
+ )
548
+ gap = self.rng.randint(*DAYS_SECOND_PAYMENT_GAP)
549
+ second_day = self._add_business_days(pay_day, gap)
550
+ self._schedule(
551
+ second_day,
552
+ lambda d, n=invname: self._do_payment(d, n, amount=None),
553
+ )
554
+ else:
555
+ self._schedule(
556
+ pay_day,
557
+ lambda d, n=invname: self._do_payment(d, n, amount=None),
558
+ )
559
+
560
+ def _do_payment(self, day: date, invname: str, amount: float | None):
561
+ from lambda_erp.accounting.payment_entry import PaymentEntry
562
+
563
+ db = get_db()
564
+ inv = db.get_value(
565
+ "Sales Invoice", invname,
566
+ ["customer", "grand_total", "outstanding_amount"],
567
+ )
568
+ if not inv:
569
+ return
570
+ outstanding = flt(inv.outstanding_amount)
571
+ if outstanding <= 0:
572
+ return
573
+
574
+ amt = outstanding if amount is None else min(flt(amount, 2), outstanding)
575
+ if amt <= 0:
576
+ return
577
+
578
+ pe = PaymentEntry(
579
+ payment_type="Receive",
580
+ posting_date=day.isoformat(),
581
+ company=self.company,
582
+ party_type="Customer",
583
+ party=inv.customer,
584
+ paid_from=f"Accounts Receivable - {self.abbr}",
585
+ paid_to=f"Primary Bank - {self.abbr}",
586
+ paid_amount=amt,
587
+ received_amount=amt,
588
+ references=[_dict(
589
+ reference_doctype="Sales Invoice",
590
+ reference_name=invname,
591
+ total_amount=flt(inv.grand_total),
592
+ outstanding_amount=outstanding,
593
+ allocated_amount=amt,
594
+ )],
595
+ )
596
+ pe.save()
597
+ pe.submit()
598
+ self.stats["sales_payments"] += 1
599
+
600
+ # ----- purchasing (reorder-driven) -----
601
+
602
+ def _check_reorder_points(self, day: date):
603
+ for code, info in self.reorder_info.items():
604
+ if info["open_po"] > 0:
605
+ continue
606
+ bal = get_stock_balance(code, self.warehouse)
607
+ on_hand = flt(bal.actual_qty) if bal else 0
608
+ if on_hand > info["level"]:
609
+ continue
610
+ self._create_po(day, code, info)
611
+
612
+ def _create_po(self, day: date, code: str, info: dict):
613
+ from lambda_erp.buying.purchase_order import PurchaseOrder
614
+
615
+ po = PurchaseOrder(
616
+ supplier=info["supplier"],
617
+ company=self.company,
618
+ transaction_date=day.isoformat(),
619
+ items=[_dict(
620
+ item_code=code,
621
+ qty=info["qty"],
622
+ rate=info["rate"],
623
+ warehouse=self.warehouse,
624
+ )],
625
+ )
626
+ po.save()
627
+ po.submit()
628
+ info["open_po"] += 1
629
+ self.stats["purchase_orders"] += 1
630
+
631
+ poname = po.name
632
+ receipt_day = self._add_business_days(day, self.rng.randint(*DAYS_PO_TO_RECEIPT))
633
+ self._schedule(
634
+ receipt_day,
635
+ lambda d, n=poname, c=code: self._do_purchase_receipt(d, n, c),
636
+ )
637
+
638
+ def _do_purchase_receipt(self, day: date, poname: str, code: str):
639
+ from lambda_erp.stock.purchase_receipt import make_purchase_receipt
640
+
641
+ pr = make_purchase_receipt(poname)
642
+ if not pr.get("items"):
643
+ return
644
+ pr.posting_date = day.isoformat()
645
+ pr.save()
646
+ pr.submit()
647
+ self.stats["purchase_receipts"] += 1
648
+
649
+ pi_day = self._add_business_days(day, self.rng.randint(*DAYS_PR_TO_INVOICE))
650
+ self._schedule(
651
+ pi_day,
652
+ lambda d, n=poname, c=code: self._do_purchase_invoice(d, n, c),
653
+ )
654
+
655
+ def _do_purchase_invoice(self, day: date, poname: str, code: str):
656
+ from lambda_erp.buying.purchase_order import make_purchase_invoice
657
+
658
+ pi = make_purchase_invoice(poname)
659
+ pi.posting_date = day.isoformat()
660
+ pi.due_date = add_days(day, DAYS_PI_NET).isoformat()
661
+ pi.save()
662
+ pi.submit()
663
+ self.reorder_info[code]["open_po"] = max(0, self.reorder_info[code]["open_po"] - 1)
664
+ self.stats["purchase_invoices"] += 1
665
+
666
+ piname = pi.name
667
+ pay_day = self._add_business_days(day, DAYS_PI_NET + self.rng.randint(*DAYS_PI_PAYMENT_VARIANCE))
668
+ self._schedule(pay_day, lambda d, n=piname: self._do_purchase_payment(d, n))
669
+
670
+ def _do_purchase_payment(self, day: date, piname: str):
671
+ from lambda_erp.accounting.payment_entry import PaymentEntry
672
+
673
+ db = get_db()
674
+ pi = db.get_value(
675
+ "Purchase Invoice", piname,
676
+ ["supplier", "grand_total", "outstanding_amount"],
677
+ )
678
+ if not pi:
679
+ return
680
+ outstanding = flt(pi.outstanding_amount)
681
+ if outstanding <= 0:
682
+ return
683
+
684
+ pe = PaymentEntry(
685
+ payment_type="Pay",
686
+ posting_date=day.isoformat(),
687
+ company=self.company,
688
+ party_type="Supplier",
689
+ party=pi.supplier,
690
+ paid_from=f"Primary Bank - {self.abbr}",
691
+ paid_to=f"Accounts Payable - {self.abbr}",
692
+ paid_amount=outstanding,
693
+ received_amount=outstanding,
694
+ references=[_dict(
695
+ reference_doctype="Purchase Invoice",
696
+ reference_name=piname,
697
+ total_amount=flt(pi.grand_total),
698
+ outstanding_amount=outstanding,
699
+ allocated_amount=outstanding,
700
+ )],
701
+ )
702
+ pe.save()
703
+ pe.submit()
704
+ self.stats["purchase_payments"] += 1