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
api/routers/setup.py ADDED
@@ -0,0 +1,387 @@
1
+ """Company setup and demo data seeding."""
2
+
3
+ import hashlib
4
+ import random
5
+
6
+ from fastapi import APIRouter, Depends
7
+ from lambda_erp.database import get_db
8
+ from lambda_erp.utils import _dict, flt, nowdate
9
+ from lambda_erp.accounting.chart_of_accounts import setup_chart_of_accounts, setup_cost_center
10
+ from api.auth import require_role
11
+
12
+ # Sample US corporate addresses (streets, cities, states) used to auto-fill
13
+ # the company profile when the user doesn't supply one.
14
+ _DEMO_ADDRESSES = [
15
+ ("1200 Lakeshore Dr", "Chicago", "IL", "60611", "US"),
16
+ ("88 Market Street", "San Francisco", "CA", "94105", "US"),
17
+ ("450 Park Avenue", "New York", "NY", "10022", "US"),
18
+ ("222 Congress Ave", "Austin", "TX", "78701", "US"),
19
+ ("3700 Peachtree Rd", "Atlanta", "GA", "30326", "US"),
20
+ ("900 Biscayne Blvd", "Miami", "FL", "33132", "US"),
21
+ ("1 Pike Place", "Seattle", "WA", "98101", "US"),
22
+ ("555 California St", "San Francisco", "CA", "94104", "US"),
23
+ ("100 Federal Street", "Boston", "MA", "02110", "US"),
24
+ ("1100 Louisiana St", "Houston", "TX", "77002", "US"),
25
+ ]
26
+
27
+
28
+ def _random_address_for(company_name: str) -> dict:
29
+ """Deterministic pseudo-random address derived from the company name."""
30
+ digest = hashlib.md5(company_name.encode()).hexdigest()
31
+ rng = random.Random(int(digest[:8], 16))
32
+ street, city, _state, zip_code, country = rng.choice(_DEMO_ADDRESSES)
33
+ tax_id = f"US-{rng.randint(10, 99)}-{rng.randint(1000000, 9999999)}"
34
+ phone = f"+1-555-{rng.randint(100, 999)}-{rng.randint(1000, 9999)}"
35
+ local = "".join(c for c in company_name.lower() if c.isalnum())[:20] or "contact"
36
+ email = f"hello@{local}.com"
37
+ return {
38
+ "email": email,
39
+ "phone": phone,
40
+ "address": street,
41
+ "city": city,
42
+ "zip_code": zip_code,
43
+ "country": country,
44
+ "tax_id": tax_id,
45
+ }
46
+
47
+ router = APIRouter(prefix="/setup", tags=["setup"])
48
+
49
+
50
+ @router.get("/status")
51
+ def setup_status():
52
+ """Check if any company exists (for first-run detection)."""
53
+ db = get_db()
54
+ companies = db.get_all(
55
+ "Company",
56
+ fields=[
57
+ "name", "company_name", "default_currency",
58
+ "email", "phone", "address", "city", "zip_code", "country", "tax_id",
59
+ ],
60
+ )
61
+ return {
62
+ "setup_complete": len(companies) > 0,
63
+ "companies": [dict(c) for c in companies],
64
+ }
65
+
66
+
67
+ @router.post("/company")
68
+ def create_company(data: dict, _user: dict = Depends(require_role("admin"))):
69
+ """Create a new company with Chart of Accounts and Cost Center."""
70
+ db = get_db()
71
+ name = data.get("name")
72
+ currency = data.get("currency", "USD")
73
+
74
+ if not name:
75
+ return {"detail": "Company name is required"}
76
+
77
+ if db.exists("Company", name):
78
+ return {"detail": f"Company {name} already exists"}
79
+
80
+ # Auto-fill contact info with a deterministic random address if the caller
81
+ # didn't supply one. This keeps the PDFs looking complete for demo users.
82
+ auto = _random_address_for(name)
83
+ db.insert("Company", _dict(
84
+ name=name,
85
+ company_name=name,
86
+ default_currency=currency,
87
+ email=data.get("email") or auto["email"],
88
+ phone=data.get("phone") or auto["phone"],
89
+ address=data.get("address") or auto["address"],
90
+ city=data.get("city") or auto["city"],
91
+ zip_code=data.get("zip_code") or auto["zip_code"],
92
+ country=data.get("country") or auto["country"],
93
+ tax_id=data.get("tax_id") or auto["tax_id"],
94
+ ))
95
+
96
+ setup_chart_of_accounts(name, currency)
97
+ cost_center = setup_cost_center(name)
98
+
99
+ return {
100
+ "ok": True,
101
+ "company": name,
102
+ "cost_center": cost_center,
103
+ "currency": currency,
104
+ }
105
+
106
+
107
+ @router.post("/seed-demo")
108
+ def seed_demo(_user: dict = Depends(require_role("admin"))):
109
+ """Seed demo master data (customers, suppliers, items, warehouse).
110
+
111
+ Now a thin wrapper around HistoricalSimulator with simulate_activity=False,
112
+ so both this endpoint and /seed-history share one source of truth for the
113
+ demo customer/supplier/item catalog.
114
+ """
115
+ from lambda_erp.simulation import HistoricalSimulator
116
+
117
+ db = get_db()
118
+ companies = db.get_all("Company", fields=["name"])
119
+ if not companies:
120
+ return {"detail": "Create a company first via POST /api/setup/company"}
121
+
122
+ company = companies[0]["name"]
123
+ sim = HistoricalSimulator(
124
+ company=company,
125
+ start=nowdate(),
126
+ end=nowdate(),
127
+ )
128
+ sim.run(simulate_activity=False)
129
+
130
+ return {"ok": True, "company": company}
131
+
132
+
133
+ @router.post("/seed-history")
134
+ def seed_history(data: dict, _user: dict = Depends(require_role("admin"))):
135
+ """Seed ~3 years of simulated business activity.
136
+
137
+ Walks business days (skipping weekends + US federal holidays) and generates
138
+ quotations, sales orders, deliveries, invoices, payments, and the
139
+ reorder-driven purchasing that keeps stock available. Seasonality and YoY
140
+ growth are baked in; the RNG seed makes runs reproducible.
141
+
142
+ Expects: {
143
+ "start_date": "2023-04-20", # optional, default: 3 years ago
144
+ "end_date": "2026-04-20", # optional, default: today
145
+ "seed": 42, # optional, default: 42
146
+ "intensity": 1.0 # optional, multiplier on quote volume
147
+ }
148
+ """
149
+ from lambda_erp.simulation import HistoricalSimulator
150
+
151
+ db = get_db()
152
+ companies = db.get_all("Company", fields=["name"])
153
+ if not companies:
154
+ return {"detail": "Create a company first via POST /api/setup/company"}
155
+
156
+ company = companies[0]["name"]
157
+
158
+ today = nowdate()
159
+ start = data.get("start_date")
160
+ end = data.get("end_date", today)
161
+ if not start:
162
+ from datetime import date, timedelta
163
+ start = (date.fromisoformat(today) - timedelta(days=365 * 3)).isoformat()
164
+
165
+ # Skip the simulation itself when a world already exists so re-running
166
+ # this endpoint is safe (and useful for backfilling the chat-demo settings
167
+ # on an already-seeded DB). The `ensure_demo_chat_records` call below
168
+ # still runs and fills any gaps.
169
+ existing_qtn = db.sql('SELECT COUNT(*) as cnt FROM "Quotation"')[0]["cnt"]
170
+ if existing_qtn == 0:
171
+ sim = HistoricalSimulator(
172
+ company=company,
173
+ start=start,
174
+ end=end,
175
+ seed=int(data.get("seed", 42)),
176
+ intensity=float(data.get("intensity", 1.0)),
177
+ )
178
+ stats = sim.run()
179
+ else:
180
+ stats = {"skipped": "quotations already exist"}
181
+
182
+ # Also set up the docs + Settings that the scripted chat replay uses. This
183
+ # way the admin-triggered history seed produces the same demo-ready state
184
+ # as the auto-boot path, so enabling public_manager locally "just works".
185
+ from api.bootstrap import ensure_demo_chat_records
186
+ ensure_demo_chat_records(company)
187
+
188
+ return {
189
+ "ok": True,
190
+ "company": company,
191
+ "start_date": start,
192
+ "end_date": end,
193
+ "stats": stats,
194
+ }
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Opening Balances
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ @router.post("/opening-balances/accounts")
203
+ def import_account_balances(data: dict, _user: dict = Depends(require_role("admin"))):
204
+ """Create a Journal Entry for opening account balances.
205
+
206
+ Expects: {
207
+ "company": "...",
208
+ "posting_date": "2026-01-01",
209
+ "entries": [
210
+ {"account": "Accounts Receivable - LAMB", "debit": 5000, "credit": 0},
211
+ {"account": "Primary Bank - LAMB", "debit": 10000, "credit": 0},
212
+ {"account": "Accounts Payable - LAMB", "debit": 0, "credit": 3000},
213
+ ...
214
+ ]
215
+ }
216
+ The difference is automatically balanced against Opening Balance Equity.
217
+ """
218
+ from lambda_erp.accounting.journal_entry import JournalEntry
219
+
220
+ company = data.get("company")
221
+ if not company:
222
+ return {"detail": "Company is required"}
223
+
224
+ posting_date = data.get("posting_date", nowdate())
225
+ entries = data.get("entries", [])
226
+ if not entries:
227
+ return {"detail": "At least one account entry is required"}
228
+
229
+ db = get_db()
230
+ abbr = company[:4].upper()
231
+ equity_account = f"Opening Balance Equity - {abbr}"
232
+
233
+ accounts = []
234
+ total_debit = 0
235
+ total_credit = 0
236
+
237
+ for e in entries:
238
+ debit = flt(e.get("debit", 0), 2)
239
+ credit = flt(e.get("credit", 0), 2)
240
+ if not debit and not credit:
241
+ continue
242
+ accounts.append(_dict(
243
+ account=e["account"],
244
+ debit=debit,
245
+ credit=credit,
246
+ party_type=e.get("party_type", ""),
247
+ party=e.get("party", ""),
248
+ ))
249
+ total_debit += debit
250
+ total_credit += credit
251
+
252
+ # Balance against Opening Balance Equity
253
+ diff = flt(total_debit - total_credit, 2)
254
+ if diff > 0:
255
+ accounts.append(_dict(account=equity_account, debit=0, credit=diff))
256
+ elif diff < 0:
257
+ accounts.append(_dict(account=equity_account, debit=abs(diff), credit=0))
258
+
259
+ je = JournalEntry(
260
+ posting_date=posting_date,
261
+ company=company,
262
+ remark=f"Opening balances as of {posting_date}",
263
+ accounts=accounts,
264
+ )
265
+ je.save()
266
+ je.submit()
267
+
268
+ return {"ok": True, "journal_entry": je.name, "total_debit": total_debit, "total_credit": total_credit}
269
+
270
+
271
+ @router.post("/opening-balances/stock")
272
+ def import_stock_balances(data: dict, _user: dict = Depends(require_role("admin"))):
273
+ """Create a Stock Entry (Opening Stock) for opening inventory.
274
+
275
+ Expects: {
276
+ "company": "...",
277
+ "posting_date": "2026-01-01",
278
+ "warehouse": "Main Warehouse - LAMB",
279
+ "items": [
280
+ {"item_code": "ITEM-001", "qty": 100, "rate": 60},
281
+ {"item_code": "ITEM-002", "qty": 50, "rate": 180},
282
+ ...
283
+ ]
284
+ }
285
+ """
286
+ from lambda_erp.stock.stock_entry import StockEntry
287
+
288
+ company = data.get("company")
289
+ if not company:
290
+ return {"detail": "Company is required"}
291
+
292
+ posting_date = data.get("posting_date", nowdate())
293
+ warehouse = data.get("warehouse")
294
+ items = data.get("items", [])
295
+ if not items:
296
+ return {"detail": "At least one item is required"}
297
+ if not warehouse:
298
+ return {"detail": "Warehouse is required"}
299
+
300
+ se_items = []
301
+ for item in items:
302
+ qty = flt(item.get("qty", 0))
303
+ rate = flt(item.get("rate", 0))
304
+ if qty <= 0:
305
+ continue
306
+ se_items.append(_dict(
307
+ item_code=item["item_code"],
308
+ qty=qty,
309
+ basic_rate=rate,
310
+ t_warehouse=warehouse,
311
+ ))
312
+
313
+ se = StockEntry(
314
+ stock_entry_type="Opening Stock",
315
+ posting_date=posting_date,
316
+ company=company,
317
+ to_warehouse=warehouse,
318
+ items=se_items,
319
+ )
320
+ se.save()
321
+ se.submit()
322
+
323
+ return {"ok": True, "stock_entry": se.name, "items_count": len(se_items)}
324
+
325
+
326
+ @router.post("/opening-balances/invoices")
327
+ def import_outstanding_invoices(data: dict, _user: dict = Depends(require_role("admin"))):
328
+ """Create submitted invoices for outstanding AR/AP balances.
329
+
330
+ Expects: {
331
+ "company": "...",
332
+ "invoices": [
333
+ {"type": "sales", "party": "CUST-001", "amount": 5000, "due_date": "2026-02-15", "remarks": "INV-OLD-001"},
334
+ {"type": "purchase", "party": "SUPP-001", "amount": 3000, "due_date": "2026-03-01"},
335
+ ...
336
+ ]
337
+ }
338
+ """
339
+ from lambda_erp.accounting.sales_invoice import SalesInvoice
340
+ from lambda_erp.accounting.purchase_invoice import PurchaseInvoice
341
+
342
+ company = data.get("company")
343
+ if not company:
344
+ return {"detail": "Company is required"}
345
+
346
+ invoices = data.get("invoices", [])
347
+ if not invoices:
348
+ return {"detail": "At least one invoice is required"}
349
+
350
+ db = get_db()
351
+ results = []
352
+
353
+ for inv in invoices:
354
+ inv_type = inv.get("type", "sales")
355
+ party = inv.get("party")
356
+ amount = flt(inv.get("amount", 0), 2)
357
+ due_date = inv.get("due_date")
358
+ posting_date = inv.get("posting_date", nowdate())
359
+ remarks = inv.get("remarks", f"Opening balance — {party}")
360
+
361
+ if not party or amount <= 0:
362
+ continue
363
+
364
+ if inv_type == "sales":
365
+ doc = SalesInvoice(
366
+ customer=party,
367
+ company=company,
368
+ posting_date=posting_date,
369
+ due_date=due_date,
370
+ remarks=remarks,
371
+ items=[_dict(item_code="OPENING", item_name="Opening Balance", qty=1, rate=amount)],
372
+ )
373
+ else:
374
+ doc = PurchaseInvoice(
375
+ supplier=party,
376
+ company=company,
377
+ posting_date=posting_date,
378
+ due_date=due_date,
379
+ remarks=remarks,
380
+ items=[_dict(item_code="OPENING", item_name="Opening Balance", qty=1, rate=amount)],
381
+ )
382
+
383
+ doc.save()
384
+ doc.submit()
385
+ results.append({"name": doc.name, "type": inv_type, "party": party, "amount": amount})
386
+
387
+ return {"ok": True, "invoices_created": len(results), "invoices": results}