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.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
lambda_erp/simulation.py
ADDED
|
@@ -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
|