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/reports.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""Reporting endpoints: Trial Balance, General Ledger, Stock Balance, P&L, Balance Sheet, Aging."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Query
|
|
6
|
+
from lambda_erp.database import get_db
|
|
7
|
+
from lambda_erp.utils import flt, nowdate
|
|
8
|
+
from lambda_erp.controllers.currency import get_exchange_rate
|
|
9
|
+
from api.auth import require_role
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/reports", tags=["reports"], dependencies=[Depends(require_role("viewer"))])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _scale_amounts(obj, rate):
|
|
15
|
+
"""Recursively multiply every numeric amount by `rate` (booleans/strings
|
|
16
|
+
left alone). Safe for the financial statements, where every number is money."""
|
|
17
|
+
if isinstance(obj, bool):
|
|
18
|
+
return obj
|
|
19
|
+
if isinstance(obj, (int, float)):
|
|
20
|
+
return flt(obj * rate, 2)
|
|
21
|
+
if isinstance(obj, dict):
|
|
22
|
+
return {k: _scale_amounts(v, rate) for k, v in obj.items()}
|
|
23
|
+
if isinstance(obj, list):
|
|
24
|
+
return [_scale_amounts(v, rate) for v in obj]
|
|
25
|
+
return obj
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _present(db, report, company, presentation_currency, rate_date):
|
|
29
|
+
"""Re-express a base-currency report in a presentation currency for display
|
|
30
|
+
only (a convenience translation at a single closing rate). The stored ledger
|
|
31
|
+
is never changed. A trial balance still balances since every line scales by
|
|
32
|
+
the same rate."""
|
|
33
|
+
base_ccy = (db.get_value("Company", company, "default_currency") if company else None) or "USD"
|
|
34
|
+
if not presentation_currency or presentation_currency == base_ccy:
|
|
35
|
+
report["base_currency"] = base_ccy
|
|
36
|
+
report["presentation_currency"] = base_ccy
|
|
37
|
+
report["presentation_rate"] = 1.0
|
|
38
|
+
return report
|
|
39
|
+
rate = get_exchange_rate(base_ccy, presentation_currency, rate_date or nowdate())
|
|
40
|
+
presented = _scale_amounts(report, rate)
|
|
41
|
+
presented["base_currency"] = base_ccy
|
|
42
|
+
presented["presentation_currency"] = presentation_currency
|
|
43
|
+
presented["presentation_rate"] = flt(rate, 6)
|
|
44
|
+
return presented
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _trial_balance(db, company=None, from_date=None, to_date=None):
|
|
48
|
+
filters = {"is_group": 0}
|
|
49
|
+
if company:
|
|
50
|
+
filters["company"] = company
|
|
51
|
+
accounts = db.get_all(
|
|
52
|
+
"Account", filters=filters,
|
|
53
|
+
fields=["name", "account_name", "root_type", "report_type"],
|
|
54
|
+
order_by="root_type, name",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
date_clause = ""
|
|
58
|
+
params = []
|
|
59
|
+
if from_date:
|
|
60
|
+
date_clause += " AND posting_date >= ?"
|
|
61
|
+
params.append(from_date)
|
|
62
|
+
if to_date:
|
|
63
|
+
date_clause += " AND posting_date <= ?"
|
|
64
|
+
params.append(to_date)
|
|
65
|
+
if company:
|
|
66
|
+
date_clause += " AND company = ?"
|
|
67
|
+
params.append(company)
|
|
68
|
+
|
|
69
|
+
gl_data = db.sql(
|
|
70
|
+
f"""SELECT account,
|
|
71
|
+
COALESCE(SUM(debit), 0) as total_debit,
|
|
72
|
+
COALESCE(SUM(credit), 0) as total_credit
|
|
73
|
+
FROM "GL Entry"
|
|
74
|
+
WHERE is_cancelled = 0 {date_clause}
|
|
75
|
+
GROUP BY account""",
|
|
76
|
+
params,
|
|
77
|
+
)
|
|
78
|
+
gl_map = {row["account"]: row for row in gl_data}
|
|
79
|
+
|
|
80
|
+
rows = []
|
|
81
|
+
total_debit = 0
|
|
82
|
+
total_credit = 0
|
|
83
|
+
for acc in accounts:
|
|
84
|
+
gl = gl_map.get(acc["name"])
|
|
85
|
+
if not gl:
|
|
86
|
+
continue
|
|
87
|
+
debit = flt(gl["total_debit"], 2)
|
|
88
|
+
credit = flt(gl["total_credit"], 2)
|
|
89
|
+
if not debit and not credit:
|
|
90
|
+
continue
|
|
91
|
+
balance = flt(debit - credit, 2)
|
|
92
|
+
rows.append({
|
|
93
|
+
"account": acc["name"],
|
|
94
|
+
"account_name": acc["account_name"],
|
|
95
|
+
"root_type": acc["root_type"],
|
|
96
|
+
"report_type": acc["report_type"],
|
|
97
|
+
"debit": debit,
|
|
98
|
+
"credit": credit,
|
|
99
|
+
"balance": balance,
|
|
100
|
+
})
|
|
101
|
+
total_debit += debit
|
|
102
|
+
total_credit += credit
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"rows": rows,
|
|
106
|
+
"total_debit": flt(total_debit, 2),
|
|
107
|
+
"total_credit": flt(total_credit, 2),
|
|
108
|
+
"difference": flt(total_debit - total_credit, 2),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.get("/trial-balance")
|
|
113
|
+
def trial_balance(
|
|
114
|
+
company: str | None = None,
|
|
115
|
+
from_date: str | None = None,
|
|
116
|
+
to_date: str | None = None,
|
|
117
|
+
presentation_currency: str | None = None,
|
|
118
|
+
):
|
|
119
|
+
db = get_db()
|
|
120
|
+
return _present(db, _trial_balance(db, company, from_date, to_date),
|
|
121
|
+
company, presentation_currency, to_date)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _general_ledger(db, filters=None):
|
|
125
|
+
filters = filters or {}
|
|
126
|
+
where = ["is_cancelled = 0"]
|
|
127
|
+
params = []
|
|
128
|
+
|
|
129
|
+
if filters.get("account"):
|
|
130
|
+
where.append("account = ?")
|
|
131
|
+
params.append(filters["account"])
|
|
132
|
+
if filters.get("party"):
|
|
133
|
+
where.append("party = ?")
|
|
134
|
+
params.append(filters["party"])
|
|
135
|
+
if filters.get("voucher_type"):
|
|
136
|
+
where.append("voucher_type = ?")
|
|
137
|
+
params.append(filters["voucher_type"])
|
|
138
|
+
if filters.get("from_date"):
|
|
139
|
+
where.append("posting_date >= ?")
|
|
140
|
+
params.append(filters["from_date"])
|
|
141
|
+
if filters.get("to_date"):
|
|
142
|
+
where.append("posting_date <= ?")
|
|
143
|
+
params.append(filters["to_date"])
|
|
144
|
+
if filters.get("company"):
|
|
145
|
+
where.append("company = ?")
|
|
146
|
+
params.append(filters["company"])
|
|
147
|
+
|
|
148
|
+
limit = int(filters.get("limit", 200))
|
|
149
|
+
offset = int(filters.get("offset", 0))
|
|
150
|
+
where_str = " AND ".join(where)
|
|
151
|
+
|
|
152
|
+
# Total count for pagination controls.
|
|
153
|
+
total = db.sql(
|
|
154
|
+
f'SELECT COUNT(*) AS n FROM "GL Entry" WHERE {where_str}',
|
|
155
|
+
params,
|
|
156
|
+
)[0]["n"]
|
|
157
|
+
|
|
158
|
+
# Running balance must still be correct on page N: sum the net of every
|
|
159
|
+
# row that precedes this page's first row, then accumulate within the
|
|
160
|
+
# page. Sub-select avoids pulling the whole ledger into memory.
|
|
161
|
+
opening_balance = 0
|
|
162
|
+
if offset > 0:
|
|
163
|
+
opening_rows = db.sql(
|
|
164
|
+
f"""SELECT COALESCE(SUM(debit - credit), 0) AS opening
|
|
165
|
+
FROM (
|
|
166
|
+
SELECT debit, credit FROM "GL Entry"
|
|
167
|
+
WHERE {where_str}
|
|
168
|
+
ORDER BY posting_date, name
|
|
169
|
+
LIMIT ?
|
|
170
|
+
)""",
|
|
171
|
+
params + [offset],
|
|
172
|
+
)
|
|
173
|
+
opening_balance = flt(opening_rows[0]["opening"]) if opening_rows else 0
|
|
174
|
+
|
|
175
|
+
rows = db.sql(
|
|
176
|
+
f"""SELECT posting_date, account, party_type, party,
|
|
177
|
+
debit, credit, voucher_type, voucher_no, remarks
|
|
178
|
+
FROM "GL Entry"
|
|
179
|
+
WHERE {where_str}
|
|
180
|
+
ORDER BY posting_date, name
|
|
181
|
+
LIMIT ? OFFSET ?""",
|
|
182
|
+
params + [limit, offset],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
balance = opening_balance
|
|
186
|
+
result = []
|
|
187
|
+
for row in rows:
|
|
188
|
+
balance += flt(row["debit"]) - flt(row["credit"])
|
|
189
|
+
entry = dict(row)
|
|
190
|
+
entry["balance"] = flt(balance, 2)
|
|
191
|
+
result.append(entry)
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
"rows": result,
|
|
195
|
+
"total": int(total or 0),
|
|
196
|
+
"limit": limit,
|
|
197
|
+
"offset": offset,
|
|
198
|
+
"opening_balance": flt(opening_balance, 2),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@router.get("/general-ledger")
|
|
203
|
+
def general_ledger(
|
|
204
|
+
account: str | None = None,
|
|
205
|
+
party: str | None = None,
|
|
206
|
+
voucher_type: str | None = None,
|
|
207
|
+
from_date: str | None = None,
|
|
208
|
+
to_date: str | None = None,
|
|
209
|
+
company: str | None = None,
|
|
210
|
+
limit: int = Query(default=50, le=500),
|
|
211
|
+
offset: int = Query(default=0, ge=0),
|
|
212
|
+
):
|
|
213
|
+
filters = {}
|
|
214
|
+
if account: filters["account"] = account
|
|
215
|
+
if party: filters["party"] = party
|
|
216
|
+
if voucher_type: filters["voucher_type"] = voucher_type
|
|
217
|
+
if from_date: filters["from_date"] = from_date
|
|
218
|
+
if to_date: filters["to_date"] = to_date
|
|
219
|
+
if company: filters["company"] = company
|
|
220
|
+
filters["limit"] = limit
|
|
221
|
+
filters["offset"] = offset
|
|
222
|
+
return _general_ledger(get_db(), filters)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _stock_balance(db, item_code=None, warehouse=None):
|
|
226
|
+
filters = {}
|
|
227
|
+
if item_code:
|
|
228
|
+
filters["item_code"] = item_code
|
|
229
|
+
if warehouse:
|
|
230
|
+
filters["warehouse"] = warehouse
|
|
231
|
+
|
|
232
|
+
bins = db.get_all(
|
|
233
|
+
"Bin",
|
|
234
|
+
filters=filters if filters else None,
|
|
235
|
+
fields=["item_code", "warehouse", "actual_qty", "ordered_qty",
|
|
236
|
+
"reserved_qty", "valuation_rate", "stock_value"],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
result = []
|
|
240
|
+
for b in bins:
|
|
241
|
+
item_name = db.get_value("Item", b["item_code"], "item_name") or b["item_code"]
|
|
242
|
+
entry = dict(b)
|
|
243
|
+
entry["item_name"] = item_name
|
|
244
|
+
result.append(entry)
|
|
245
|
+
|
|
246
|
+
return {"rows": result}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@router.get("/stock-balance")
|
|
250
|
+
def stock_balance(
|
|
251
|
+
item_code: str | None = None,
|
|
252
|
+
warehouse: str | None = None,
|
|
253
|
+
):
|
|
254
|
+
return _stock_balance(get_db(), item_code, warehouse)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _dashboard_summary(db, company=None):
|
|
258
|
+
"""Compute dashboard summary metrics."""
|
|
259
|
+
|
|
260
|
+
company_filter = ""
|
|
261
|
+
params = []
|
|
262
|
+
if company:
|
|
263
|
+
company_filter = " AND company = ?"
|
|
264
|
+
params.append(company)
|
|
265
|
+
|
|
266
|
+
# Amounts are summed in base currency: each invoice's document-currency
|
|
267
|
+
# figure is scaled by its own (historical) conversion_rate, so foreign
|
|
268
|
+
# invoices aren't added in as if they were base-currency amounts.
|
|
269
|
+
|
|
270
|
+
# Total revenue (submitted sales invoices)
|
|
271
|
+
revenue = db.sql(
|
|
272
|
+
f'SELECT COALESCE(SUM(grand_total * COALESCE(conversion_rate, 1)), 0) as total '
|
|
273
|
+
f'FROM "Sales Invoice" WHERE docstatus = 1{company_filter}',
|
|
274
|
+
params,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Outstanding receivable
|
|
278
|
+
receivable = db.sql(
|
|
279
|
+
f'SELECT COALESCE(SUM(outstanding_amount * COALESCE(conversion_rate, 1)), 0) as total '
|
|
280
|
+
f'FROM "Sales Invoice" WHERE docstatus = 1 AND outstanding_amount > 0{company_filter}',
|
|
281
|
+
params,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Outstanding payable
|
|
285
|
+
payable = db.sql(
|
|
286
|
+
f'SELECT COALESCE(SUM(outstanding_amount * COALESCE(conversion_rate, 1)), 0) as total '
|
|
287
|
+
f'FROM "Purchase Invoice" WHERE docstatus = 1 AND outstanding_amount > 0{company_filter}',
|
|
288
|
+
params,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Total stock value
|
|
292
|
+
stock_value = db.sql(
|
|
293
|
+
'SELECT COALESCE(SUM(stock_value), 0) as total FROM "Bin"', [],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Recent documents (last 10 across key types)
|
|
297
|
+
recent = []
|
|
298
|
+
for doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Sales Order", "Quotation"]:
|
|
299
|
+
docs = db.get_all(
|
|
300
|
+
doctype,
|
|
301
|
+
fields=["name", "status", "docstatus", "creation"],
|
|
302
|
+
order_by="creation DESC",
|
|
303
|
+
limit=3,
|
|
304
|
+
)
|
|
305
|
+
for d in docs:
|
|
306
|
+
entry = dict(d)
|
|
307
|
+
entry["doctype"] = doctype
|
|
308
|
+
recent.append(entry)
|
|
309
|
+
|
|
310
|
+
recent.sort(key=lambda x: x.get("creation", ""), reverse=True)
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
"total_revenue": flt(revenue[0]["total"], 2) if revenue else 0,
|
|
314
|
+
"outstanding_receivable": flt(receivable[0]["total"], 2) if receivable else 0,
|
|
315
|
+
"outstanding_payable": flt(payable[0]["total"], 2) if payable else 0,
|
|
316
|
+
"total_stock_value": flt(stock_value[0]["total"], 2) if stock_value else 0,
|
|
317
|
+
"recent_documents": recent[:10],
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@router.get("/dashboard-summary")
|
|
322
|
+
def dashboard_summary(company: str | None = None):
|
|
323
|
+
return _dashboard_summary(get_db(), company)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- Profit & Loss ---
|
|
327
|
+
|
|
328
|
+
def _profit_and_loss(db, company=None, from_date=None, to_date=None):
|
|
329
|
+
"""Income and Expense accounts grouped by root_type."""
|
|
330
|
+
filters = {"is_group": 0}
|
|
331
|
+
if company:
|
|
332
|
+
filters["company"] = company
|
|
333
|
+
|
|
334
|
+
accounts = db.get_all(
|
|
335
|
+
"Account", filters=filters,
|
|
336
|
+
fields=["name", "account_name", "root_type", "report_type"],
|
|
337
|
+
order_by="root_type, name",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
date_clause = ""
|
|
341
|
+
params = []
|
|
342
|
+
if from_date:
|
|
343
|
+
date_clause += " AND posting_date >= ?"
|
|
344
|
+
params.append(from_date)
|
|
345
|
+
if to_date:
|
|
346
|
+
date_clause += " AND posting_date <= ?"
|
|
347
|
+
params.append(to_date)
|
|
348
|
+
if company:
|
|
349
|
+
date_clause += " AND company = ?"
|
|
350
|
+
params.append(company)
|
|
351
|
+
|
|
352
|
+
gl_data = db.sql(
|
|
353
|
+
f"""SELECT account,
|
|
354
|
+
COALESCE(SUM(debit), 0) as total_debit,
|
|
355
|
+
COALESCE(SUM(credit), 0) as total_credit
|
|
356
|
+
FROM "GL Entry"
|
|
357
|
+
WHERE is_cancelled = 0 {date_clause}
|
|
358
|
+
GROUP BY account""",
|
|
359
|
+
params,
|
|
360
|
+
)
|
|
361
|
+
gl_map = {row["account"]: row for row in gl_data}
|
|
362
|
+
|
|
363
|
+
income_rows = []
|
|
364
|
+
expense_rows = []
|
|
365
|
+
total_income = 0
|
|
366
|
+
total_expense = 0
|
|
367
|
+
|
|
368
|
+
for acc in accounts:
|
|
369
|
+
if acc["root_type"] not in ("Income", "Expense"):
|
|
370
|
+
continue
|
|
371
|
+
gl = gl_map.get(acc["name"])
|
|
372
|
+
if not gl:
|
|
373
|
+
continue
|
|
374
|
+
debit = flt(gl["total_debit"], 2)
|
|
375
|
+
credit = flt(gl["total_credit"], 2)
|
|
376
|
+
if not debit and not credit:
|
|
377
|
+
continue
|
|
378
|
+
# Income: credit balance (positive = earned). Expense: debit balance (positive = spent).
|
|
379
|
+
balance = flt(credit - debit, 2) if acc["root_type"] == "Income" else flt(debit - credit, 2)
|
|
380
|
+
row = {
|
|
381
|
+
"account": acc["name"],
|
|
382
|
+
"account_name": acc["account_name"],
|
|
383
|
+
"root_type": acc["root_type"],
|
|
384
|
+
"amount": abs(balance),
|
|
385
|
+
}
|
|
386
|
+
if acc["root_type"] == "Income":
|
|
387
|
+
income_rows.append(row)
|
|
388
|
+
total_income += abs(balance)
|
|
389
|
+
else:
|
|
390
|
+
expense_rows.append(row)
|
|
391
|
+
total_expense += abs(balance)
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
"income": income_rows,
|
|
395
|
+
"expense": expense_rows,
|
|
396
|
+
"total_income": flt(total_income, 2),
|
|
397
|
+
"total_expense": flt(total_expense, 2),
|
|
398
|
+
"net_profit": flt(total_income - total_expense, 2),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@router.get("/profit-and-loss")
|
|
403
|
+
def profit_and_loss(
|
|
404
|
+
company: str | None = None,
|
|
405
|
+
from_date: str | None = None,
|
|
406
|
+
to_date: str | None = None,
|
|
407
|
+
presentation_currency: str | None = None,
|
|
408
|
+
):
|
|
409
|
+
db = get_db()
|
|
410
|
+
return _present(db, _profit_and_loss(db, company, from_date, to_date),
|
|
411
|
+
company, presentation_currency, to_date)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --- Balance Sheet ---
|
|
415
|
+
|
|
416
|
+
def _balance_sheet(db, company=None, as_of_date=None):
|
|
417
|
+
"""Asset, Liability, and Equity accounts as of a date."""
|
|
418
|
+
filters = {"is_group": 0}
|
|
419
|
+
if company:
|
|
420
|
+
filters["company"] = company
|
|
421
|
+
|
|
422
|
+
accounts = db.get_all(
|
|
423
|
+
"Account", filters=filters,
|
|
424
|
+
fields=["name", "account_name", "root_type", "report_type"],
|
|
425
|
+
order_by="root_type, name",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
date_clause = ""
|
|
429
|
+
params = []
|
|
430
|
+
if as_of_date:
|
|
431
|
+
date_clause += " AND posting_date <= ?"
|
|
432
|
+
params.append(as_of_date)
|
|
433
|
+
if company:
|
|
434
|
+
date_clause += " AND company = ?"
|
|
435
|
+
params.append(company)
|
|
436
|
+
|
|
437
|
+
gl_data = db.sql(
|
|
438
|
+
f"""SELECT account,
|
|
439
|
+
COALESCE(SUM(debit), 0) as total_debit,
|
|
440
|
+
COALESCE(SUM(credit), 0) as total_credit
|
|
441
|
+
FROM "GL Entry"
|
|
442
|
+
WHERE is_cancelled = 0 {date_clause}
|
|
443
|
+
GROUP BY account""",
|
|
444
|
+
params,
|
|
445
|
+
)
|
|
446
|
+
gl_map = {row["account"]: row for row in gl_data}
|
|
447
|
+
|
|
448
|
+
asset_rows = []
|
|
449
|
+
liability_rows = []
|
|
450
|
+
equity_rows = []
|
|
451
|
+
total_asset = 0
|
|
452
|
+
total_liability = 0
|
|
453
|
+
total_equity = 0
|
|
454
|
+
|
|
455
|
+
for acc in accounts:
|
|
456
|
+
if acc["root_type"] not in ("Asset", "Liability", "Equity"):
|
|
457
|
+
continue
|
|
458
|
+
gl = gl_map.get(acc["name"])
|
|
459
|
+
if not gl:
|
|
460
|
+
continue
|
|
461
|
+
debit = flt(gl["total_debit"], 2)
|
|
462
|
+
credit = flt(gl["total_credit"], 2)
|
|
463
|
+
if not debit and not credit:
|
|
464
|
+
continue
|
|
465
|
+
# Asset: debit balance. Liability/Equity: credit balance.
|
|
466
|
+
if acc["root_type"] == "Asset":
|
|
467
|
+
balance = flt(debit - credit, 2)
|
|
468
|
+
else:
|
|
469
|
+
balance = flt(credit - debit, 2)
|
|
470
|
+
row = {
|
|
471
|
+
"account": acc["name"],
|
|
472
|
+
"account_name": acc["account_name"],
|
|
473
|
+
"root_type": acc["root_type"],
|
|
474
|
+
"balance": balance,
|
|
475
|
+
}
|
|
476
|
+
if acc["root_type"] == "Asset":
|
|
477
|
+
asset_rows.append(row)
|
|
478
|
+
total_asset += balance
|
|
479
|
+
elif acc["root_type"] == "Liability":
|
|
480
|
+
liability_rows.append(row)
|
|
481
|
+
total_liability += balance
|
|
482
|
+
else:
|
|
483
|
+
equity_rows.append(row)
|
|
484
|
+
total_equity += balance
|
|
485
|
+
|
|
486
|
+
# Add retained earnings (net P&L) to equity
|
|
487
|
+
pl = _profit_and_loss(db, company, to_date=as_of_date)
|
|
488
|
+
net_profit = flt(pl["net_profit"], 2)
|
|
489
|
+
if net_profit:
|
|
490
|
+
equity_rows.append({
|
|
491
|
+
"account": "Retained Earnings (Current Period)",
|
|
492
|
+
"account_name": "Retained Earnings (Current Period)",
|
|
493
|
+
"root_type": "Equity",
|
|
494
|
+
"balance": net_profit,
|
|
495
|
+
})
|
|
496
|
+
total_equity += net_profit
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
"assets": asset_rows,
|
|
500
|
+
"liabilities": liability_rows,
|
|
501
|
+
"equity": equity_rows,
|
|
502
|
+
"total_assets": flt(total_asset, 2),
|
|
503
|
+
"total_liabilities": flt(total_liability, 2),
|
|
504
|
+
"total_equity": flt(total_equity, 2),
|
|
505
|
+
"total_liabilities_and_equity": flt(total_liability + total_equity, 2),
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@router.get("/balance-sheet")
|
|
510
|
+
def balance_sheet(
|
|
511
|
+
company: str | None = None,
|
|
512
|
+
as_of_date: str | None = None,
|
|
513
|
+
presentation_currency: str | None = None,
|
|
514
|
+
):
|
|
515
|
+
db = get_db()
|
|
516
|
+
return _present(db, _balance_sheet(db, company, as_of_date),
|
|
517
|
+
company, presentation_currency, as_of_date)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# --- Accounts Receivable Aging ---
|
|
521
|
+
|
|
522
|
+
def _ar_aging(db, company=None, as_of_date=None):
|
|
523
|
+
"""Outstanding Sales Invoices bucketed by age, as of a given date.
|
|
524
|
+
|
|
525
|
+
Rebuilds the outstanding balance historically rather than using the
|
|
526
|
+
current `outstanding_amount` column, so setting as_of_date to a past
|
|
527
|
+
date actually "rewinds" the ledger:
|
|
528
|
+
|
|
529
|
+
outstanding_at_date = grand_total
|
|
530
|
+
- Payment Entries allocated ≤ as_of_date
|
|
531
|
+
- Credit Notes (return SIs) posted ≤ as_of_date
|
|
532
|
+
"""
|
|
533
|
+
if not as_of_date:
|
|
534
|
+
as_of_date = date.today().isoformat()
|
|
535
|
+
|
|
536
|
+
where = [
|
|
537
|
+
"si.docstatus = 1",
|
|
538
|
+
"COALESCE(si.is_return, 0) = 0",
|
|
539
|
+
"si.posting_date <= ?",
|
|
540
|
+
]
|
|
541
|
+
params: list = [as_of_date]
|
|
542
|
+
if company:
|
|
543
|
+
where.append("si.company = ?")
|
|
544
|
+
params.append(company)
|
|
545
|
+
|
|
546
|
+
# Per-invoice outstanding_at_date is computed with two correlated
|
|
547
|
+
# sub-queries: sum allocations and sum credit-note reductions, both
|
|
548
|
+
# filtered to documents that existed on as_of_date.
|
|
549
|
+
invoices = db.sql(
|
|
550
|
+
f"""SELECT si.name, si.customer, si.posting_date, si.due_date,
|
|
551
|
+
si.grand_total, COALESCE(si.conversion_rate, 1) AS conversion_rate,
|
|
552
|
+
si.grand_total
|
|
553
|
+
- COALESCE((
|
|
554
|
+
SELECT SUM(per.allocated_amount)
|
|
555
|
+
FROM "Payment Entry Reference" per
|
|
556
|
+
JOIN "Payment Entry" pe ON pe.name = per.parent
|
|
557
|
+
WHERE pe.docstatus = 1
|
|
558
|
+
AND pe.posting_date <= ?
|
|
559
|
+
AND per.reference_doctype = 'Sales Invoice'
|
|
560
|
+
AND per.reference_name = si.name
|
|
561
|
+
), 0)
|
|
562
|
+
- COALESCE((
|
|
563
|
+
SELECT SUM(ABS(sir.grand_total))
|
|
564
|
+
FROM "Sales Invoice" sir
|
|
565
|
+
WHERE sir.docstatus = 1
|
|
566
|
+
AND sir.is_return = 1
|
|
567
|
+
AND sir.return_against = si.name
|
|
568
|
+
AND sir.posting_date <= ?
|
|
569
|
+
), 0) AS outstanding_at_date
|
|
570
|
+
FROM "Sales Invoice" si
|
|
571
|
+
WHERE {' AND '.join(where)}
|
|
572
|
+
ORDER BY si.due_date""",
|
|
573
|
+
[as_of_date, as_of_date] + params,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
rows = []
|
|
577
|
+
totals = {"outstanding": 0, "current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
|
|
578
|
+
|
|
579
|
+
for inv in invoices:
|
|
580
|
+
# outstanding_at_date is in the invoice's document currency; scale by its
|
|
581
|
+
# own historical rate so the report aggregates in base currency.
|
|
582
|
+
outstanding = flt(flt(inv["outstanding_at_date"], 2) * flt(inv["conversion_rate"] or 1), 2)
|
|
583
|
+
if outstanding <= 0:
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
due = inv["due_date"] or inv["posting_date"]
|
|
587
|
+
days_overdue = (date.fromisoformat(as_of_date) - date.fromisoformat(due)).days
|
|
588
|
+
if days_overdue < 0:
|
|
589
|
+
days_overdue = 0
|
|
590
|
+
|
|
591
|
+
bucket = {"current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
|
|
592
|
+
if days_overdue == 0:
|
|
593
|
+
bucket["current"] = outstanding
|
|
594
|
+
elif days_overdue <= 30:
|
|
595
|
+
bucket["b1_30"] = outstanding
|
|
596
|
+
elif days_overdue <= 60:
|
|
597
|
+
bucket["b31_60"] = outstanding
|
|
598
|
+
elif days_overdue <= 90:
|
|
599
|
+
bucket["b61_90"] = outstanding
|
|
600
|
+
else:
|
|
601
|
+
bucket["b90_plus"] = outstanding
|
|
602
|
+
|
|
603
|
+
customer_name = db.get_value("Customer", inv["customer"], "customer_name") or inv["customer"]
|
|
604
|
+
rows.append({
|
|
605
|
+
"invoice": inv["name"],
|
|
606
|
+
"customer": inv["customer"],
|
|
607
|
+
"customer_name": customer_name,
|
|
608
|
+
"posting_date": inv["posting_date"],
|
|
609
|
+
"due_date": due,
|
|
610
|
+
"days_overdue": max(days_overdue, 0),
|
|
611
|
+
"outstanding": outstanding,
|
|
612
|
+
**bucket,
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
totals["outstanding"] += outstanding
|
|
616
|
+
for k in bucket:
|
|
617
|
+
totals[k] += bucket[k]
|
|
618
|
+
|
|
619
|
+
for k in totals:
|
|
620
|
+
totals[k] = flt(totals[k], 2)
|
|
621
|
+
|
|
622
|
+
return {"rows": rows, "totals": totals, "as_of_date": as_of_date}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@router.get("/ar-aging")
|
|
626
|
+
def ar_aging(
|
|
627
|
+
company: str | None = None,
|
|
628
|
+
as_of_date: str | None = None,
|
|
629
|
+
):
|
|
630
|
+
return _ar_aging(get_db(), company, as_of_date)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# --- Accounts Payable Aging ---
|
|
634
|
+
|
|
635
|
+
def _ap_aging(db, company=None, as_of_date=None):
|
|
636
|
+
"""Outstanding Purchase Invoices bucketed by age, as of a given date.
|
|
637
|
+
|
|
638
|
+
Mirror image of _ar_aging — rebuilds outstanding historically so
|
|
639
|
+
past-date queries reflect what was open on that date.
|
|
640
|
+
"""
|
|
641
|
+
if not as_of_date:
|
|
642
|
+
as_of_date = date.today().isoformat()
|
|
643
|
+
|
|
644
|
+
where = [
|
|
645
|
+
"pi.docstatus = 1",
|
|
646
|
+
"COALESCE(pi.is_return, 0) = 0",
|
|
647
|
+
"pi.posting_date <= ?",
|
|
648
|
+
]
|
|
649
|
+
params: list = [as_of_date]
|
|
650
|
+
if company:
|
|
651
|
+
where.append("pi.company = ?")
|
|
652
|
+
params.append(company)
|
|
653
|
+
|
|
654
|
+
invoices = db.sql(
|
|
655
|
+
f"""SELECT pi.name, pi.supplier, pi.posting_date, pi.due_date,
|
|
656
|
+
pi.grand_total, COALESCE(pi.conversion_rate, 1) AS conversion_rate,
|
|
657
|
+
pi.grand_total
|
|
658
|
+
- COALESCE((
|
|
659
|
+
SELECT SUM(per.allocated_amount)
|
|
660
|
+
FROM "Payment Entry Reference" per
|
|
661
|
+
JOIN "Payment Entry" pe ON pe.name = per.parent
|
|
662
|
+
WHERE pe.docstatus = 1
|
|
663
|
+
AND pe.posting_date <= ?
|
|
664
|
+
AND per.reference_doctype = 'Purchase Invoice'
|
|
665
|
+
AND per.reference_name = pi.name
|
|
666
|
+
), 0)
|
|
667
|
+
- COALESCE((
|
|
668
|
+
SELECT SUM(ABS(pir.grand_total))
|
|
669
|
+
FROM "Purchase Invoice" pir
|
|
670
|
+
WHERE pir.docstatus = 1
|
|
671
|
+
AND pir.is_return = 1
|
|
672
|
+
AND pir.return_against = pi.name
|
|
673
|
+
AND pir.posting_date <= ?
|
|
674
|
+
), 0) AS outstanding_at_date
|
|
675
|
+
FROM "Purchase Invoice" pi
|
|
676
|
+
WHERE {' AND '.join(where)}
|
|
677
|
+
ORDER BY pi.due_date""",
|
|
678
|
+
[as_of_date, as_of_date] + params,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
rows = []
|
|
682
|
+
totals = {"outstanding": 0, "current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
|
|
683
|
+
|
|
684
|
+
for inv in invoices:
|
|
685
|
+
# outstanding_at_date is in the invoice's document currency; scale by its
|
|
686
|
+
# own historical rate so the report aggregates in base currency.
|
|
687
|
+
outstanding = flt(flt(inv["outstanding_at_date"], 2) * flt(inv["conversion_rate"] or 1), 2)
|
|
688
|
+
if outstanding <= 0:
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
due = inv["due_date"] or inv["posting_date"]
|
|
692
|
+
days_overdue = (date.fromisoformat(as_of_date) - date.fromisoformat(due)).days
|
|
693
|
+
if days_overdue < 0:
|
|
694
|
+
days_overdue = 0
|
|
695
|
+
|
|
696
|
+
bucket = {"current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
|
|
697
|
+
if days_overdue == 0:
|
|
698
|
+
bucket["current"] = outstanding
|
|
699
|
+
elif days_overdue <= 30:
|
|
700
|
+
bucket["b1_30"] = outstanding
|
|
701
|
+
elif days_overdue <= 60:
|
|
702
|
+
bucket["b31_60"] = outstanding
|
|
703
|
+
elif days_overdue <= 90:
|
|
704
|
+
bucket["b61_90"] = outstanding
|
|
705
|
+
else:
|
|
706
|
+
bucket["b90_plus"] = outstanding
|
|
707
|
+
|
|
708
|
+
supplier_name = db.get_value("Supplier", inv["supplier"], "supplier_name") or inv["supplier"]
|
|
709
|
+
rows.append({
|
|
710
|
+
"invoice": inv["name"],
|
|
711
|
+
"supplier": inv["supplier"],
|
|
712
|
+
"supplier_name": supplier_name,
|
|
713
|
+
"posting_date": inv["posting_date"],
|
|
714
|
+
"due_date": due,
|
|
715
|
+
"days_overdue": max(days_overdue, 0),
|
|
716
|
+
"outstanding": outstanding,
|
|
717
|
+
**bucket,
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
totals["outstanding"] += outstanding
|
|
721
|
+
for k in bucket:
|
|
722
|
+
totals[k] += bucket[k]
|
|
723
|
+
|
|
724
|
+
for k in totals:
|
|
725
|
+
totals[k] = flt(totals[k], 2)
|
|
726
|
+
|
|
727
|
+
return {"rows": rows, "totals": totals, "as_of_date": as_of_date}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@router.get("/ap-aging")
|
|
731
|
+
def ap_aging(
|
|
732
|
+
company: str | None = None,
|
|
733
|
+
as_of_date: str | None = None,
|
|
734
|
+
):
|
|
735
|
+
return _ap_aging(get_db(), company, as_of_date)
|