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
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}
|