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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Bank reconciliation API endpoints."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends
|
|
4
|
+
from lambda_erp.accounting.bank_transaction import reconcile_bank_transaction
|
|
5
|
+
from lambda_erp.accounting.subscription import Subscription
|
|
6
|
+
from api.auth import require_role
|
|
7
|
+
|
|
8
|
+
router = APIRouter(tags=["bank-reconciliation"], dependencies=[Depends(require_role("manager"))])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.post("/bank-reconciliation/match")
|
|
12
|
+
def match_transaction(data: dict):
|
|
13
|
+
"""Match a bank transaction to a payment entry or invoice."""
|
|
14
|
+
bt_name = data.get("bank_transaction")
|
|
15
|
+
ref_doctype = data.get("reference_doctype")
|
|
16
|
+
ref_name = data.get("reference_name")
|
|
17
|
+
|
|
18
|
+
if not bt_name or not ref_doctype or not ref_name:
|
|
19
|
+
return {"detail": "bank_transaction, reference_doctype, and reference_name are required"}
|
|
20
|
+
|
|
21
|
+
return reconcile_bank_transaction(bt_name, ref_doctype, ref_name)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.post("/documents/subscription/{name}/process")
|
|
25
|
+
def process_subscription(name: str):
|
|
26
|
+
"""Process a subscription to generate the next invoice if due."""
|
|
27
|
+
sub = Subscription.load(name)
|
|
28
|
+
result = sub.process()
|
|
29
|
+
if result:
|
|
30
|
+
return {"status": "invoice_created", "invoice": result}
|
|
31
|
+
return {"status": "no_invoice_due"}
|
api/routers/documents.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Generic CRUD routes for all document types."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Query
|
|
4
|
+
from fastapi.responses import Response
|
|
5
|
+
from api.services import (
|
|
6
|
+
create_document,
|
|
7
|
+
load_document,
|
|
8
|
+
update_document,
|
|
9
|
+
submit_document,
|
|
10
|
+
cancel_document,
|
|
11
|
+
convert_document,
|
|
12
|
+
list_documents,
|
|
13
|
+
count_documents,
|
|
14
|
+
)
|
|
15
|
+
from api.pdf import generate_pdf
|
|
16
|
+
from api.auth import require_role
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/documents", tags=["documents"])
|
|
19
|
+
|
|
20
|
+
_viewer = Depends(require_role("viewer"))
|
|
21
|
+
_manager = Depends(require_role("manager"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/{doctype_slug}")
|
|
25
|
+
def list_docs(
|
|
26
|
+
doctype_slug: str,
|
|
27
|
+
status: str | None = None,
|
|
28
|
+
party: str | None = None,
|
|
29
|
+
from_date: str | None = None,
|
|
30
|
+
to_date: str | None = None,
|
|
31
|
+
docstatus: int | None = None,
|
|
32
|
+
limit: int = Query(default=50, le=500),
|
|
33
|
+
offset: int = Query(default=0, ge=0),
|
|
34
|
+
_user: dict = _viewer,
|
|
35
|
+
):
|
|
36
|
+
filters = {}
|
|
37
|
+
if status:
|
|
38
|
+
filters["status"] = status
|
|
39
|
+
if docstatus is not None:
|
|
40
|
+
filters["docstatus"] = docstatus
|
|
41
|
+
if party:
|
|
42
|
+
filters["customer"] = party
|
|
43
|
+
if from_date:
|
|
44
|
+
filters["from_date"] = from_date
|
|
45
|
+
if to_date:
|
|
46
|
+
filters["to_date"] = to_date
|
|
47
|
+
rows = list_documents(doctype_slug, filters=filters, limit=limit, offset=offset)
|
|
48
|
+
total = count_documents(doctype_slug, filters=filters)
|
|
49
|
+
return {"rows": rows, "total": total, "limit": limit, "offset": offset}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/{doctype_slug}/search")
|
|
53
|
+
def search_docs(doctype_slug: str, q: str = "", limit: int = Query(default=10, le=50), _user: dict = _viewer):
|
|
54
|
+
docs = list_documents(doctype_slug, limit=limit)
|
|
55
|
+
if q:
|
|
56
|
+
docs = [d for d in docs if q.lower() in d.get("name", "").lower()]
|
|
57
|
+
return [{"name": d["name"]} for d in docs]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/{doctype_slug}/{name}/pdf")
|
|
61
|
+
def get_pdf(doctype_slug: str, name: str, _user: dict = _viewer):
|
|
62
|
+
pdf_bytes = generate_pdf(doctype_slug, name)
|
|
63
|
+
return Response(
|
|
64
|
+
content=pdf_bytes,
|
|
65
|
+
media_type="application/pdf",
|
|
66
|
+
headers={"Content-Disposition": f'inline; filename="{name}.pdf"'},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.get("/{doctype_slug}/{name}")
|
|
71
|
+
def get_doc(doctype_slug: str, name: str, _user: dict = _viewer):
|
|
72
|
+
return load_document(doctype_slug, name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.post("/{doctype_slug}")
|
|
76
|
+
def create_doc(doctype_slug: str, data: dict, _user: dict = _manager):
|
|
77
|
+
return create_document(doctype_slug, data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.put("/{doctype_slug}/{name}")
|
|
81
|
+
def update_doc(doctype_slug: str, name: str, data: dict, _user: dict = _manager):
|
|
82
|
+
return update_document(doctype_slug, name, data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/{doctype_slug}/{name}/submit")
|
|
86
|
+
def submit_doc(doctype_slug: str, name: str, _user: dict = _manager):
|
|
87
|
+
return submit_document(doctype_slug, name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.post("/{doctype_slug}/{name}/cancel")
|
|
91
|
+
def cancel_doc(doctype_slug: str, name: str, _user: dict = _manager):
|
|
92
|
+
return cancel_document(doctype_slug, name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("/{doctype_slug}/{name}/convert")
|
|
96
|
+
def convert_doc(doctype_slug: str, name: str, data: dict, _user: dict = _manager):
|
|
97
|
+
target = data.get("target_doctype")
|
|
98
|
+
if not target:
|
|
99
|
+
return {"detail": "target_doctype is required"}
|
|
100
|
+
return convert_document(doctype_slug, name, target)
|
api/routers/masters.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Master data CRUD: Customer, Supplier, Item, Warehouse, Account, Company."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
6
|
+
from lambda_erp.database import get_db
|
|
7
|
+
from lambda_erp.utils import _dict
|
|
8
|
+
from api.services import MASTER_TABLES
|
|
9
|
+
from api.auth import require_role, require_non_public_manager
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/masters", tags=["masters"])
|
|
12
|
+
|
|
13
|
+
_viewer = Depends(require_role("viewer"))
|
|
14
|
+
_manager = Depends(require_non_public_manager)
|
|
15
|
+
_admin = Depends(require_role("admin"))
|
|
16
|
+
|
|
17
|
+
MASTER_NAME_PREFIXES = {
|
|
18
|
+
"customer": "CUST",
|
|
19
|
+
"supplier": "SUPP",
|
|
20
|
+
"item": "ITEM",
|
|
21
|
+
"warehouse": "WH",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# An Item's code is stored in the primary-key column `name`, but every
|
|
25
|
+
# transaction line and report references it as
|
|
26
|
+
# `item_code`. Accept that intuitive alias on the master API so callers
|
|
27
|
+
# (LLM tools, REST clients) can set and read the code under the name they
|
|
28
|
+
# already use everywhere else, instead of silently falling back to ITEM-NNN.
|
|
29
|
+
MASTER_IDENTITY_ALIAS = {
|
|
30
|
+
"item": "item_code",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _echo_identity_alias(master_type: str, row):
|
|
35
|
+
"""Mirror the master's `name` back under its intuitive alias (item_code)."""
|
|
36
|
+
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
37
|
+
if alias and isinstance(row, dict) and row.get("name") is not None:
|
|
38
|
+
row[alias] = row["name"]
|
|
39
|
+
return row
|
|
40
|
+
|
|
41
|
+
DELETE_REFERENCE_CHECKS = {
|
|
42
|
+
"company": [
|
|
43
|
+
('SELECT 1 FROM "Account" WHERE company = ? LIMIT 1', "account"),
|
|
44
|
+
('SELECT 1 FROM "Cost Center" WHERE company = ? LIMIT 1', "cost center"),
|
|
45
|
+
('SELECT 1 FROM "Warehouse" WHERE company = ? LIMIT 1', "warehouse"),
|
|
46
|
+
('SELECT 1 FROM "Fiscal Year" WHERE company = ? LIMIT 1', "fiscal year"),
|
|
47
|
+
('SELECT 1 FROM "Tax Template" WHERE company = ? LIMIT 1', "tax template"),
|
|
48
|
+
('SELECT 1 FROM "GL Entry" WHERE company = ? LIMIT 1', "GL entry"),
|
|
49
|
+
('SELECT 1 FROM "Quotation" WHERE company = ? LIMIT 1', "quotation"),
|
|
50
|
+
('SELECT 1 FROM "Sales Order" WHERE company = ? LIMIT 1', "sales order"),
|
|
51
|
+
('SELECT 1 FROM "Purchase Order" WHERE company = ? LIMIT 1', "purchase order"),
|
|
52
|
+
('SELECT 1 FROM "Sales Invoice" WHERE company = ? LIMIT 1', "sales invoice"),
|
|
53
|
+
('SELECT 1 FROM "Purchase Invoice" WHERE company = ? LIMIT 1', "purchase invoice"),
|
|
54
|
+
('SELECT 1 FROM "Payment Entry" WHERE company = ? LIMIT 1', "payment entry"),
|
|
55
|
+
('SELECT 1 FROM "Journal Entry" WHERE company = ? LIMIT 1', "journal entry"),
|
|
56
|
+
('SELECT 1 FROM "Stock Entry" WHERE company = ? LIMIT 1', "stock entry"),
|
|
57
|
+
('SELECT 1 FROM "Delivery Note" WHERE company = ? LIMIT 1', "delivery note"),
|
|
58
|
+
('SELECT 1 FROM "Purchase Receipt" WHERE company = ? LIMIT 1', "purchase receipt"),
|
|
59
|
+
('SELECT 1 FROM "POS Invoice" WHERE company = ? LIMIT 1', "POS invoice"),
|
|
60
|
+
('SELECT 1 FROM "Budget" WHERE company = ? LIMIT 1', "budget"),
|
|
61
|
+
('SELECT 1 FROM "Subscription" WHERE company = ? LIMIT 1', "subscription"),
|
|
62
|
+
('SELECT 1 FROM "Bank Transaction" WHERE company = ? LIMIT 1', "bank transaction"),
|
|
63
|
+
],
|
|
64
|
+
"customer": [
|
|
65
|
+
('SELECT 1 FROM "Quotation" WHERE customer = ? LIMIT 1', "quotation"),
|
|
66
|
+
('SELECT 1 FROM "Sales Order" WHERE customer = ? LIMIT 1', "sales order"),
|
|
67
|
+
('SELECT 1 FROM "Sales Invoice" WHERE customer = ? LIMIT 1', "sales invoice"),
|
|
68
|
+
('SELECT 1 FROM "Delivery Note" WHERE customer = ? LIMIT 1', "delivery note"),
|
|
69
|
+
('SELECT 1 FROM "POS Invoice" WHERE customer = ? LIMIT 1', "POS invoice"),
|
|
70
|
+
('SELECT 1 FROM "Payment Entry" WHERE party_type = "Customer" AND party = ? LIMIT 1', "payment entry"),
|
|
71
|
+
('SELECT 1 FROM "Subscription" WHERE party_type = "Customer" AND party = ? LIMIT 1', "subscription"),
|
|
72
|
+
],
|
|
73
|
+
"supplier": [
|
|
74
|
+
('SELECT 1 FROM "Purchase Order" WHERE supplier = ? LIMIT 1', "purchase order"),
|
|
75
|
+
('SELECT 1 FROM "Purchase Invoice" WHERE supplier = ? LIMIT 1', "purchase invoice"),
|
|
76
|
+
('SELECT 1 FROM "Purchase Receipt" WHERE supplier = ? LIMIT 1', "purchase receipt"),
|
|
77
|
+
('SELECT 1 FROM "Payment Entry" WHERE party_type = "Supplier" AND party = ? LIMIT 1', "payment entry"),
|
|
78
|
+
('SELECT 1 FROM "Subscription" WHERE party_type = "Supplier" AND party = ? LIMIT 1', "subscription"),
|
|
79
|
+
],
|
|
80
|
+
"item": [
|
|
81
|
+
('SELECT 1 FROM "Quotation Item" WHERE item_code = ? LIMIT 1', "quotation item"),
|
|
82
|
+
('SELECT 1 FROM "Sales Order Item" WHERE item_code = ? LIMIT 1', "sales order item"),
|
|
83
|
+
('SELECT 1 FROM "Purchase Order Item" WHERE item_code = ? LIMIT 1', "purchase order item"),
|
|
84
|
+
('SELECT 1 FROM "Delivery Note Item" WHERE item_code = ? LIMIT 1', "delivery note item"),
|
|
85
|
+
('SELECT 1 FROM "Purchase Receipt Item" WHERE item_code = ? LIMIT 1', "purchase receipt item"),
|
|
86
|
+
('SELECT 1 FROM "Sales Invoice Item" WHERE item_code = ? LIMIT 1', "sales invoice item"),
|
|
87
|
+
('SELECT 1 FROM "Purchase Invoice Item" WHERE item_code = ? LIMIT 1', "purchase invoice item"),
|
|
88
|
+
('SELECT 1 FROM "POS Invoice Item" WHERE item_code = ? LIMIT 1', "POS invoice item"),
|
|
89
|
+
('SELECT 1 FROM "Stock Entry Detail" WHERE item_code = ? LIMIT 1', "stock entry item"),
|
|
90
|
+
('SELECT 1 FROM "Stock Ledger Entry" WHERE item_code = ? LIMIT 1', "stock ledger entry"),
|
|
91
|
+
('SELECT 1 FROM "Bin" WHERE item_code = ? LIMIT 1', "bin"),
|
|
92
|
+
('SELECT 1 FROM "Pricing Rule" WHERE item_code = ? LIMIT 1', "pricing rule"),
|
|
93
|
+
('SELECT 1 FROM "Subscription Plan" WHERE item_code = ? LIMIT 1', "subscription plan"),
|
|
94
|
+
],
|
|
95
|
+
"warehouse": [
|
|
96
|
+
('SELECT 1 FROM "Item" WHERE default_warehouse = ? LIMIT 1', "item"),
|
|
97
|
+
('SELECT 1 FROM "Warehouse" WHERE parent_warehouse = ? LIMIT 1', "child warehouse"),
|
|
98
|
+
('SELECT 1 FROM "Quotation Item" WHERE warehouse = ? LIMIT 1', "quotation item"),
|
|
99
|
+
('SELECT 1 FROM "Sales Order Item" WHERE warehouse = ? LIMIT 1', "sales order item"),
|
|
100
|
+
('SELECT 1 FROM "Purchase Order Item" WHERE warehouse = ? LIMIT 1', "purchase order item"),
|
|
101
|
+
('SELECT 1 FROM "Delivery Note Item" WHERE warehouse = ? LIMIT 1', "delivery note item"),
|
|
102
|
+
('SELECT 1 FROM "Purchase Receipt Item" WHERE warehouse = ? LIMIT 1', "purchase receipt item"),
|
|
103
|
+
('SELECT 1 FROM "Sales Invoice Item" WHERE warehouse = ? LIMIT 1', "sales invoice item"),
|
|
104
|
+
('SELECT 1 FROM "Purchase Invoice Item" WHERE warehouse = ? LIMIT 1', "purchase invoice item"),
|
|
105
|
+
('SELECT 1 FROM "POS Invoice Item" WHERE warehouse = ? LIMIT 1', "POS invoice item"),
|
|
106
|
+
('SELECT 1 FROM "Stock Entry" WHERE from_warehouse = ? OR to_warehouse = ? LIMIT 1', "stock entry"),
|
|
107
|
+
('SELECT 1 FROM "Stock Entry Detail" WHERE s_warehouse = ? OR t_warehouse = ? LIMIT 1', "stock entry item"),
|
|
108
|
+
('SELECT 1 FROM "Stock Ledger Entry" WHERE warehouse = ? LIMIT 1', "stock ledger entry"),
|
|
109
|
+
('SELECT 1 FROM "Bin" WHERE warehouse = ? LIMIT 1', "bin"),
|
|
110
|
+
],
|
|
111
|
+
"account": [
|
|
112
|
+
('SELECT 1 FROM "Account" WHERE parent_account = ? LIMIT 1', "child account"),
|
|
113
|
+
('SELECT 1 FROM "Cost Center" WHERE parent_cost_center = ? LIMIT 1', "cost center"), # defensive, should not match normally
|
|
114
|
+
('SELECT 1 FROM "GL Entry" WHERE account = ? LIMIT 1', "GL entry"),
|
|
115
|
+
('SELECT 1 FROM "Journal Entry Account" WHERE account = ? LIMIT 1', "journal entry account"),
|
|
116
|
+
('SELECT 1 FROM "Payment Entry" WHERE paid_from = ? OR paid_to = ? LIMIT 1', "payment entry"),
|
|
117
|
+
('SELECT 1 FROM "Sales Taxes and Charges" WHERE account_head = ? LIMIT 1', "tax row"),
|
|
118
|
+
('SELECT 1 FROM "Tax Template Detail" WHERE account_head = ? LIMIT 1', "tax template detail"),
|
|
119
|
+
('SELECT 1 FROM "Company" WHERE round_off_account = ? OR default_receivable_account = ? OR default_payable_account = ? OR default_income_account = ? OR default_expense_account = ? OR stock_received_but_not_billed = ? OR stock_adjustment_account = ? OR accumulated_depreciation_account = ? OR depreciation_expense_account = ? LIMIT 1', "company"),
|
|
120
|
+
('SELECT 1 FROM "Warehouse" WHERE account = ? LIMIT 1', "warehouse"),
|
|
121
|
+
('SELECT 1 FROM "Pricing Rule" WHERE discount_account = ? LIMIT 1', "pricing rule"),
|
|
122
|
+
('SELECT 1 FROM "Budget" WHERE account = ? LIMIT 1', "budget"),
|
|
123
|
+
('SELECT 1 FROM "Bank Transaction" WHERE bank_account = ? LIMIT 1', "bank transaction"),
|
|
124
|
+
],
|
|
125
|
+
"cost-center": [
|
|
126
|
+
('SELECT 1 FROM "Cost Center" WHERE parent_cost_center = ? LIMIT 1', "child cost center"),
|
|
127
|
+
('SELECT 1 FROM "GL Entry" WHERE cost_center = ? LIMIT 1', "GL entry"),
|
|
128
|
+
('SELECT 1 FROM "Journal Entry Account" WHERE cost_center = ? LIMIT 1', "journal entry account"),
|
|
129
|
+
('SELECT 1 FROM "Sales Invoice Item" WHERE cost_center = ? LIMIT 1', "sales invoice item"),
|
|
130
|
+
('SELECT 1 FROM "Purchase Invoice Item" WHERE cost_center = ? LIMIT 1', "purchase invoice item"),
|
|
131
|
+
('SELECT 1 FROM "POS Invoice Item" WHERE cost_center = ? LIMIT 1', "POS invoice item"),
|
|
132
|
+
('SELECT 1 FROM "Company" WHERE default_cost_center = ? OR round_off_cost_center = ? LIMIT 1', "company"),
|
|
133
|
+
('SELECT 1 FROM "Budget" WHERE cost_center = ? LIMIT 1', "budget"),
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get_table(master_type: str):
|
|
139
|
+
entry = MASTER_TABLES.get(master_type)
|
|
140
|
+
if not entry:
|
|
141
|
+
return None, None
|
|
142
|
+
return entry # (doctype, name_field)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _with_active_filter(db, doctype: str, filters: dict | None = None) -> dict | None:
|
|
146
|
+
effective = dict(filters or {})
|
|
147
|
+
if "disabled" in db._get_table_columns(doctype) and "disabled" not in effective:
|
|
148
|
+
effective["disabled"] = 0
|
|
149
|
+
return effective or None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _find_reference(master_type: str, name: str) -> str | None:
|
|
153
|
+
db = get_db()
|
|
154
|
+
for query, label in DELETE_REFERENCE_CHECKS.get(master_type, []):
|
|
155
|
+
params = [name] if query.count("?") == 1 else [name] * query.count("?")
|
|
156
|
+
try:
|
|
157
|
+
if db.sql(query, params):
|
|
158
|
+
return label
|
|
159
|
+
except Exception:
|
|
160
|
+
continue
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _generate_master_name(db, doctype: str, prefix: str) -> str:
|
|
165
|
+
# Take the highest numeric suffix among strict "PREFIX-<digits>" names.
|
|
166
|
+
# Names with a non-numeric tail (e.g. a custom "ITEM-COST-TEST") are ignored
|
|
167
|
+
# rather than parsed — string-sorting them to the top used to reset the
|
|
168
|
+
# counter to 001 and collide with an existing record.
|
|
169
|
+
rows = db.sql(
|
|
170
|
+
f'SELECT name FROM "{doctype}" WHERE name LIKE ?',
|
|
171
|
+
[f"{prefix}-%"],
|
|
172
|
+
)
|
|
173
|
+
pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)$")
|
|
174
|
+
max_number = 0
|
|
175
|
+
for row in rows:
|
|
176
|
+
match = pattern.match(row["name"] or "")
|
|
177
|
+
if match:
|
|
178
|
+
max_number = max(max_number, int(match.group(1)))
|
|
179
|
+
|
|
180
|
+
# Skip any number already taken by a non-standard name so we never collide.
|
|
181
|
+
number = max_number + 1
|
|
182
|
+
while db.exists(doctype, f"{prefix}-{number:03d}"):
|
|
183
|
+
number += 1
|
|
184
|
+
return f"{prefix}-{number:03d}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _normalize_master_data(data: dict) -> dict:
|
|
188
|
+
normalized = _dict(data)
|
|
189
|
+
for key, value in list(normalized.items()):
|
|
190
|
+
if isinstance(value, str) and value.strip() == "":
|
|
191
|
+
normalized[key] = None
|
|
192
|
+
return normalized
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def create_master_record(master_type: str, data: dict) -> dict:
|
|
196
|
+
doctype, _ = _get_table(master_type)
|
|
197
|
+
if not doctype:
|
|
198
|
+
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
199
|
+
|
|
200
|
+
db = get_db()
|
|
201
|
+
doc = _normalize_master_data(data)
|
|
202
|
+
|
|
203
|
+
# Let callers set the code under its intuitive alias (item_code -> name).
|
|
204
|
+
# An explicit `name` still wins; the alias key is consumed either way so it
|
|
205
|
+
# isn't reported as an ignored field.
|
|
206
|
+
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
207
|
+
if alias:
|
|
208
|
+
alias_val = doc.pop(alias, None)
|
|
209
|
+
if alias_val and not doc.get("name"):
|
|
210
|
+
doc["name"] = alias_val
|
|
211
|
+
|
|
212
|
+
if not doc.get("name"):
|
|
213
|
+
prefix = MASTER_NAME_PREFIXES.get(master_type)
|
|
214
|
+
if prefix:
|
|
215
|
+
doc["name"] = _generate_master_name(db, doctype, prefix)
|
|
216
|
+
elif master_type == "company" and doc.get("company_name"):
|
|
217
|
+
# A company's id is conventionally its name (mirrors /setup/company).
|
|
218
|
+
doc["name"] = doc["company_name"]
|
|
219
|
+
else:
|
|
220
|
+
raise HTTPException(status_code=422, detail="Name is required")
|
|
221
|
+
|
|
222
|
+
if db.exists(doctype, doc["name"]):
|
|
223
|
+
raise HTTPException(status_code=409, detail=f"{doctype} {doc['name']} already exists")
|
|
224
|
+
|
|
225
|
+
db.insert(doctype, doc)
|
|
226
|
+
row = db.get_all(doctype, filters={"name": doc["name"]}, fields=["*"])[0]
|
|
227
|
+
return _echo_identity_alias(master_type, row)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
231
|
+
doctype, _ = _get_table(master_type)
|
|
232
|
+
if not doctype:
|
|
233
|
+
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
234
|
+
|
|
235
|
+
db = get_db()
|
|
236
|
+
normalized = _normalize_master_data(data)
|
|
237
|
+
|
|
238
|
+
# The alias (item_code) is the identity, not a mutable column. Allow it as a
|
|
239
|
+
# no-op when it matches the record being updated, but reject a rename — the
|
|
240
|
+
# code is a primary key referenced across every transaction.
|
|
241
|
+
alias = MASTER_IDENTITY_ALIAS.get(master_type)
|
|
242
|
+
if alias and alias in normalized:
|
|
243
|
+
alias_val = normalized.pop(alias)
|
|
244
|
+
if alias_val and alias_val != name:
|
|
245
|
+
raise HTTPException(
|
|
246
|
+
status_code=422,
|
|
247
|
+
detail=(
|
|
248
|
+
f"Cannot change a {master_type}'s {alias} ('{name}' -> '{alias_val}') — "
|
|
249
|
+
f"it is the record's identity. Create a new {master_type} with the desired code instead."
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
update_fields = {k: v for k, v in normalized.items() if k != "name"}
|
|
254
|
+
if update_fields:
|
|
255
|
+
db.set_value(doctype, name, update_fields)
|
|
256
|
+
|
|
257
|
+
rows = db.get_all(doctype, filters={"name": name}, fields=["*"])
|
|
258
|
+
if not rows:
|
|
259
|
+
raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
|
|
260
|
+
return _echo_identity_alias(master_type, rows[0])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.get("/{master_type}")
|
|
264
|
+
def list_masters(
|
|
265
|
+
master_type: str,
|
|
266
|
+
limit: int = Query(default=50, le=1000),
|
|
267
|
+
offset: int = Query(default=0, ge=0),
|
|
268
|
+
include_disabled: bool = False,
|
|
269
|
+
_user: dict = _viewer,
|
|
270
|
+
):
|
|
271
|
+
doctype, _ = _get_table(master_type)
|
|
272
|
+
if not doctype:
|
|
273
|
+
return {"detail": f"Unknown master type: {master_type}"}
|
|
274
|
+
db = get_db()
|
|
275
|
+
filters = None if include_disabled else _with_active_filter(db, doctype)
|
|
276
|
+
|
|
277
|
+
# Build WHERE clause and params
|
|
278
|
+
where_parts = []
|
|
279
|
+
params = []
|
|
280
|
+
if filters:
|
|
281
|
+
for k, v in filters.items():
|
|
282
|
+
if isinstance(v, (list, tuple)) and len(v) == 2:
|
|
283
|
+
op, val = v
|
|
284
|
+
where_parts.append(f'"{k}" {op} ?')
|
|
285
|
+
params.append(val)
|
|
286
|
+
else:
|
|
287
|
+
where_parts.append(f'"{k}" = ?')
|
|
288
|
+
params.append(v)
|
|
289
|
+
|
|
290
|
+
count_query = f'SELECT COUNT(*) as c FROM "{doctype}"'
|
|
291
|
+
if where_parts:
|
|
292
|
+
count_query += " WHERE " + " AND ".join(where_parts)
|
|
293
|
+
total_rows = db.sql(count_query, params)
|
|
294
|
+
total = int(total_rows[0]["c"]) if total_rows else 0
|
|
295
|
+
|
|
296
|
+
query = f'SELECT * FROM "{doctype}"'
|
|
297
|
+
if where_parts:
|
|
298
|
+
query += " WHERE " + " AND ".join(where_parts)
|
|
299
|
+
query += f" LIMIT {int(limit)}"
|
|
300
|
+
if offset:
|
|
301
|
+
query += f" OFFSET {int(offset)}"
|
|
302
|
+
rows = db.sql(query, params)
|
|
303
|
+
|
|
304
|
+
return {"rows": rows, "total": total, "limit": limit, "offset": offset}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@router.get("/{master_type}/search")
|
|
308
|
+
def search_masters(master_type: str, q: str = "", _user: dict = _viewer):
|
|
309
|
+
doctype, name_field = _get_table(master_type)
|
|
310
|
+
if not doctype:
|
|
311
|
+
return []
|
|
312
|
+
db = get_db()
|
|
313
|
+
active_prefix = 'disabled = 0 AND ' if "disabled" in db._get_table_columns(doctype) else ""
|
|
314
|
+
if not q:
|
|
315
|
+
return db.get_all(doctype, filters=_with_active_filter(db, doctype), fields=["name", name_field], limit=10)
|
|
316
|
+
|
|
317
|
+
rows = db.sql(
|
|
318
|
+
f'SELECT name, "{name_field}" FROM "{doctype}" '
|
|
319
|
+
f'WHERE {active_prefix}(name LIKE ? OR "{name_field}" LIKE ?) LIMIT 10',
|
|
320
|
+
[f"%{q}%", f"%{q}%"],
|
|
321
|
+
)
|
|
322
|
+
return rows
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@router.get("/{master_type}/{name}")
|
|
326
|
+
def get_master(master_type: str, name: str, _user: dict = _viewer):
|
|
327
|
+
doctype, _ = _get_table(master_type)
|
|
328
|
+
if not doctype:
|
|
329
|
+
return {"detail": f"Unknown master type: {master_type}"}
|
|
330
|
+
db = get_db()
|
|
331
|
+
rows = db.get_all(doctype, filters={"name": name}, fields=["*"])
|
|
332
|
+
if not rows:
|
|
333
|
+
return {"detail": f"{doctype} {name} not found"}
|
|
334
|
+
return rows[0]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@router.post("/{master_type}")
|
|
338
|
+
def create_master(master_type: str, data: dict, _user: dict = _manager):
|
|
339
|
+
return create_master_record(master_type, data)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@router.put("/{master_type}/{name}")
|
|
343
|
+
def update_master(master_type: str, name: str, data: dict, _user: dict = _manager):
|
|
344
|
+
return update_master_record(master_type, name, data)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@router.delete("/{master_type}/{name}")
|
|
348
|
+
def delete_master(master_type: str, name: str, _user: dict = _admin):
|
|
349
|
+
doctype, _ = _get_table(master_type)
|
|
350
|
+
if not doctype:
|
|
351
|
+
return {"detail": f"Unknown master type: {master_type}"}
|
|
352
|
+
db = get_db()
|
|
353
|
+
if not db.exists(doctype, name):
|
|
354
|
+
raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
|
|
355
|
+
|
|
356
|
+
reference = _find_reference(master_type, name)
|
|
357
|
+
if reference:
|
|
358
|
+
columns = db._get_table_columns(doctype)
|
|
359
|
+
if "disabled" in columns:
|
|
360
|
+
db.set_value(doctype, name, {"disabled": 1})
|
|
361
|
+
return {"ok": True, "status": "disabled", "reason": f"Referenced by {reference}"}
|
|
362
|
+
raise HTTPException(status_code=409, detail=f"Cannot delete {doctype} {name}: referenced by {reference}")
|
|
363
|
+
|
|
364
|
+
db.delete(doctype, name=name)
|
|
365
|
+
return {"ok": True, "status": "deleted"}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@router.get("/account/tree")
|
|
369
|
+
def account_tree(company: str | None = None, _user: dict = _viewer):
|
|
370
|
+
"""Return Chart of Accounts as a nested tree."""
|
|
371
|
+
db = get_db()
|
|
372
|
+
filters = {}
|
|
373
|
+
if company:
|
|
374
|
+
filters["company"] = company
|
|
375
|
+
accounts = db.get_all(
|
|
376
|
+
"Account",
|
|
377
|
+
filters=filters if filters else None,
|
|
378
|
+
fields=["name", "account_name", "parent_account", "root_type",
|
|
379
|
+
"report_type", "account_type", "is_group"],
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
by_parent = {}
|
|
383
|
+
for acc in accounts:
|
|
384
|
+
parent = acc.get("parent_account") or "__root__"
|
|
385
|
+
by_parent.setdefault(parent, []).append(acc)
|
|
386
|
+
|
|
387
|
+
def _build(parent_name):
|
|
388
|
+
children = by_parent.get(parent_name, [])
|
|
389
|
+
result = []
|
|
390
|
+
for acc in children:
|
|
391
|
+
node = dict(acc)
|
|
392
|
+
node["children"] = _build(acc["name"])
|
|
393
|
+
result.append(node)
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
return _build("__root__") or _build(None)
|