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,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purchase Order.
|
|
3
|
+
|
|
4
|
+
Purchase Order is the buying-side equivalent of Sales Order:
|
|
5
|
+
Purchase Order -> Purchase Receipt -> Purchase Invoice
|
|
6
|
+
|
|
7
|
+
Key behaviors:
|
|
8
|
+
- Does NOT create GL entries
|
|
9
|
+
- Updates ordered_qty in Bin (for MRP planning)
|
|
10
|
+
- Tracks receipt and billing status
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from lambda_erp.model import Document
|
|
14
|
+
from lambda_erp.utils import _dict, flt, getdate, nowdate
|
|
15
|
+
from lambda_erp.database import get_db
|
|
16
|
+
from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
|
|
17
|
+
from lambda_erp.controllers.defaults import set_default_currency
|
|
18
|
+
from lambda_erp.exceptions import ValidationError
|
|
19
|
+
|
|
20
|
+
class PurchaseOrder(Document):
|
|
21
|
+
DOCTYPE = "Purchase Order"
|
|
22
|
+
CHILD_TABLES = {
|
|
23
|
+
"items": ("Purchase Order Item", None),
|
|
24
|
+
"taxes": ("Sales Taxes and Charges", None),
|
|
25
|
+
}
|
|
26
|
+
PREFIX = "PO"
|
|
27
|
+
|
|
28
|
+
LINK_FIELDS = {
|
|
29
|
+
"supplier": "Supplier",
|
|
30
|
+
"company": "Company",
|
|
31
|
+
}
|
|
32
|
+
CHILD_LINK_FIELDS = {
|
|
33
|
+
"items": {
|
|
34
|
+
"item_code": "Item",
|
|
35
|
+
"warehouse": "Warehouse",
|
|
36
|
+
},
|
|
37
|
+
"taxes": {
|
|
38
|
+
"account_head": "Account",
|
|
39
|
+
"cost_center": "Cost Center",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def validate(self):
|
|
44
|
+
if not self.supplier:
|
|
45
|
+
raise ValidationError("Supplier is required")
|
|
46
|
+
if not self.get("items"):
|
|
47
|
+
raise ValidationError("At least one item is required")
|
|
48
|
+
if not self.transaction_date:
|
|
49
|
+
self.transaction_date = nowdate()
|
|
50
|
+
|
|
51
|
+
self._set_supplier_name()
|
|
52
|
+
self._set_item_defaults()
|
|
53
|
+
|
|
54
|
+
from lambda_erp.controllers.pricing_rule import apply_pricing_rules
|
|
55
|
+
apply_pricing_rules(self)
|
|
56
|
+
|
|
57
|
+
set_default_currency(self, "Supplier", "supplier")
|
|
58
|
+
|
|
59
|
+
calculate_taxes_and_totals(self)
|
|
60
|
+
|
|
61
|
+
def _set_supplier_name(self):
|
|
62
|
+
if not self.supplier_name and self.supplier:
|
|
63
|
+
db = get_db()
|
|
64
|
+
self.supplier_name = db.get_value("Supplier", self.supplier, "supplier_name")
|
|
65
|
+
|
|
66
|
+
def _set_item_defaults(self):
|
|
67
|
+
db = get_db()
|
|
68
|
+
for item in self.get("items"):
|
|
69
|
+
if item.get("item_code") and not item.get("item_name"):
|
|
70
|
+
item_data = db.get_value(
|
|
71
|
+
"Item", item["item_code"],
|
|
72
|
+
["item_name", "description", "stock_uom", "standard_rate"]
|
|
73
|
+
)
|
|
74
|
+
if item_data:
|
|
75
|
+
item["item_name"] = item_data.item_name
|
|
76
|
+
item["description"] = item.get("description") or item_data.description
|
|
77
|
+
item["uom"] = item.get("uom") or item_data.stock_uom
|
|
78
|
+
if not item.get("rate"):
|
|
79
|
+
item["rate"] = flt(item_data.standard_rate)
|
|
80
|
+
|
|
81
|
+
def on_submit(self):
|
|
82
|
+
"""Update ordered_qty in Bin for MRP planning."""
|
|
83
|
+
self._update_ordered_qty(1)
|
|
84
|
+
|
|
85
|
+
def on_cancel(self):
|
|
86
|
+
self._update_ordered_qty(-1)
|
|
87
|
+
|
|
88
|
+
def _update_ordered_qty(self, direction=1):
|
|
89
|
+
db = get_db()
|
|
90
|
+
for item in self.get("items"):
|
|
91
|
+
if item.get("warehouse") and item.get("item_code"):
|
|
92
|
+
qty = flt(item.get("qty", 0)) * direction
|
|
93
|
+
bin_data = db.get_value(
|
|
94
|
+
"Bin",
|
|
95
|
+
{"item_code": item["item_code"], "warehouse": item["warehouse"]},
|
|
96
|
+
["name", "ordered_qty"],
|
|
97
|
+
)
|
|
98
|
+
if bin_data:
|
|
99
|
+
new_ordered = flt(bin_data.ordered_qty) + qty
|
|
100
|
+
db.set_value("Bin", bin_data.name, "ordered_qty", max(0, new_ordered))
|
|
101
|
+
elif direction > 0:
|
|
102
|
+
db.insert("Bin", _dict(
|
|
103
|
+
name=f"{item['item_code']}-{item['warehouse']}",
|
|
104
|
+
item_code=item["item_code"],
|
|
105
|
+
warehouse=item["warehouse"],
|
|
106
|
+
ordered_qty=qty,
|
|
107
|
+
))
|
|
108
|
+
db.commit()
|
|
109
|
+
|
|
110
|
+
def update_receipt_status(self):
|
|
111
|
+
"""Update per_received based on received quantities."""
|
|
112
|
+
total_qty = sum(flt(item.get("qty")) for item in self.get("items"))
|
|
113
|
+
received_qty = sum(flt(item.get("received_qty")) for item in self.get("items"))
|
|
114
|
+
if total_qty:
|
|
115
|
+
self._data["per_received"] = flt(received_qty / total_qty * 100, 2)
|
|
116
|
+
self._persist()
|
|
117
|
+
|
|
118
|
+
def make_purchase_invoice(purchase_order_name):
|
|
119
|
+
"""Convert a Purchase Order into a Purchase Invoice."""
|
|
120
|
+
from lambda_erp.accounting.purchase_invoice import PurchaseInvoice
|
|
121
|
+
|
|
122
|
+
db = get_db()
|
|
123
|
+
po = PurchaseOrder.load(purchase_order_name)
|
|
124
|
+
|
|
125
|
+
if po.docstatus != 1:
|
|
126
|
+
raise ValidationError("Purchase Order must be submitted before creating Purchase Invoice")
|
|
127
|
+
|
|
128
|
+
pi = PurchaseInvoice(
|
|
129
|
+
supplier=po.supplier,
|
|
130
|
+
supplier_name=po.supplier_name,
|
|
131
|
+
company=po.company,
|
|
132
|
+
currency=po.currency,
|
|
133
|
+
conversion_rate=po.conversion_rate,
|
|
134
|
+
posting_date=nowdate(),
|
|
135
|
+
purchase_order=po.name,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
for item in po.get("items"):
|
|
139
|
+
unbilled = flt(item.get("qty")) - flt(item.get("billed_qty"))
|
|
140
|
+
if unbilled <= 0:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
pi.append("items", _dict(
|
|
144
|
+
item_code=item.get("item_code"),
|
|
145
|
+
item_name=item.get("item_name"),
|
|
146
|
+
description=item.get("description"),
|
|
147
|
+
qty=unbilled,
|
|
148
|
+
uom=item.get("uom"),
|
|
149
|
+
rate=item.get("rate"),
|
|
150
|
+
warehouse=item.get("warehouse"),
|
|
151
|
+
purchase_order=po.name,
|
|
152
|
+
purchase_order_item=item.get("name"),
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
for tax in po.get("taxes") or []:
|
|
156
|
+
pi.append("taxes", _dict(
|
|
157
|
+
charge_type=tax.get("charge_type"),
|
|
158
|
+
account_head=tax.get("account_head"),
|
|
159
|
+
description=tax.get("description"),
|
|
160
|
+
rate=tax.get("rate"),
|
|
161
|
+
tax_amount=0,
|
|
162
|
+
included_in_print_rate=tax.get("included_in_print_rate"),
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
return pi
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Currency exchange-rate lookup.
|
|
2
|
+
|
|
3
|
+
Rates live in the `Currency Exchange` table (date, from_currency, to_currency,
|
|
4
|
+
exchange_rate). A lookup carries forward the most recent rate on or before a
|
|
5
|
+
given date. The looked-up rate is snapshotted onto each document at posting
|
|
6
|
+
time (its `conversion_rate` column), so editing this table never changes a
|
|
7
|
+
book that has already been posted — the snapshot is what guarantees historical
|
|
8
|
+
reproducibility, not any immutability of the table itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from lambda_erp.database import get_db
|
|
12
|
+
from lambda_erp.utils import flt, nowdate
|
|
13
|
+
from lambda_erp.exceptions import ValidationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_exchange_rate(from_currency, to_currency, date=None):
|
|
17
|
+
"""Return units of to_currency per 1 unit of from_currency on/before date.
|
|
18
|
+
|
|
19
|
+
- Same currency (or a missing currency) -> 1.0.
|
|
20
|
+
- Otherwise the newest Currency Exchange rate with date <= the given date
|
|
21
|
+
(carry-forward). If only the reverse pair exists, its inverse is used.
|
|
22
|
+
- If no rate is found at all, raises rather than silently assuming 1.0 —
|
|
23
|
+
booking a foreign-currency document at 1.0 would corrupt the base ledger.
|
|
24
|
+
"""
|
|
25
|
+
if not from_currency or not to_currency or from_currency == to_currency:
|
|
26
|
+
return 1.0
|
|
27
|
+
|
|
28
|
+
db = get_db()
|
|
29
|
+
on_date = date or nowdate()
|
|
30
|
+
|
|
31
|
+
direct = db.sql(
|
|
32
|
+
'SELECT exchange_rate FROM "Currency Exchange" '
|
|
33
|
+
'WHERE from_currency = ? AND to_currency = ? AND date <= ? '
|
|
34
|
+
'ORDER BY date DESC LIMIT 1',
|
|
35
|
+
[from_currency, to_currency, on_date],
|
|
36
|
+
)
|
|
37
|
+
if direct and flt(direct[0]["exchange_rate"]) > 0:
|
|
38
|
+
return flt(direct[0]["exchange_rate"])
|
|
39
|
+
|
|
40
|
+
inverse = db.sql(
|
|
41
|
+
'SELECT exchange_rate FROM "Currency Exchange" '
|
|
42
|
+
'WHERE from_currency = ? AND to_currency = ? AND date <= ? '
|
|
43
|
+
'ORDER BY date DESC LIMIT 1',
|
|
44
|
+
[to_currency, from_currency, on_date],
|
|
45
|
+
)
|
|
46
|
+
if inverse and flt(inverse[0]["exchange_rate"]) > 0:
|
|
47
|
+
return flt(1.0 / flt(inverse[0]["exchange_rate"]), 6)
|
|
48
|
+
|
|
49
|
+
raise ValidationError(
|
|
50
|
+
f"No exchange rate found for {from_currency} -> {to_currency} on or before "
|
|
51
|
+
f"{on_date}. Add a Currency Exchange entry or pass conversion_rate explicitly."
|
|
52
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared default-setting helpers for transactional documents."""
|
|
2
|
+
|
|
3
|
+
from lambda_erp.database import get_db
|
|
4
|
+
from lambda_erp.utils import flt
|
|
5
|
+
from lambda_erp.controllers.currency import get_exchange_rate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_default_company(doc):
|
|
9
|
+
"""Set company to the first available company if not specified."""
|
|
10
|
+
if doc._data.get("company"):
|
|
11
|
+
return
|
|
12
|
+
db = get_db()
|
|
13
|
+
companies = db.get_all("Company", fields=["name"], limit=1)
|
|
14
|
+
if companies:
|
|
15
|
+
doc._data["company"] = companies[0]["name"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def set_default_currency(doc, party_type=None, party_field=None):
|
|
19
|
+
"""Default a transaction's currency and conversion_rate for new entries.
|
|
20
|
+
|
|
21
|
+
Currency precedence: a value already on the doc -> the party's
|
|
22
|
+
default_currency -> the company's (base/functional) default_currency ->
|
|
23
|
+
"USD".
|
|
24
|
+
|
|
25
|
+
conversion_rate is forced to 1.0 whenever the document currency equals the
|
|
26
|
+
company's base currency. For a foreign currency the caller-supplied rate is
|
|
27
|
+
kept; if none was supplied it is looked up from the Currency Exchange table
|
|
28
|
+
for the document's date. A foreign currency with no rate on file raises
|
|
29
|
+
(via get_exchange_rate) rather than silently booking at 1.0.
|
|
30
|
+
"""
|
|
31
|
+
db = get_db()
|
|
32
|
+
company = doc._data.get("company")
|
|
33
|
+
base_currency = db.get_value("Company", company, "default_currency") if company else None
|
|
34
|
+
|
|
35
|
+
currency = doc._data.get("currency")
|
|
36
|
+
if not currency:
|
|
37
|
+
if party_type and party_field:
|
|
38
|
+
party = doc._data.get(party_field)
|
|
39
|
+
if party:
|
|
40
|
+
currency = db.get_value(party_type, party, "default_currency")
|
|
41
|
+
currency = currency or base_currency or "USD"
|
|
42
|
+
doc._data["currency"] = currency
|
|
43
|
+
|
|
44
|
+
rate = flt(doc._data.get("conversion_rate"))
|
|
45
|
+
if base_currency and currency == base_currency:
|
|
46
|
+
rate = 1.0
|
|
47
|
+
elif rate <= 0:
|
|
48
|
+
# No rate supplied — look one up (carry-forward) for the doc's date.
|
|
49
|
+
doc_date = doc._data.get("posting_date") or doc._data.get("transaction_date")
|
|
50
|
+
rate = get_exchange_rate(currency, base_currency or "USD", doc_date)
|
|
51
|
+
doc._data["conversion_rate"] = rate
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pricing Rule.
|
|
3
|
+
|
|
4
|
+
Pricing Rules define automatic discounts applied to transaction items.
|
|
5
|
+
A rule matches by item_code, quantity threshold, and date range, then
|
|
6
|
+
applies either a rate override, a discount percentage, or a discount amount.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from lambda_erp.model import Document
|
|
10
|
+
from lambda_erp.utils import _dict, flt, getdate, nowdate
|
|
11
|
+
from lambda_erp.database import get_db
|
|
12
|
+
from lambda_erp.exceptions import ValidationError
|
|
13
|
+
|
|
14
|
+
class PricingRule(Document):
|
|
15
|
+
DOCTYPE = "Pricing Rule"
|
|
16
|
+
CHILD_TABLES = {}
|
|
17
|
+
PREFIX = "PRULE"
|
|
18
|
+
|
|
19
|
+
def validate(self):
|
|
20
|
+
if not self.title:
|
|
21
|
+
raise ValidationError("Title is required")
|
|
22
|
+
if not self.item_code:
|
|
23
|
+
raise ValidationError("Item Code is required")
|
|
24
|
+
if not self._data.get("selling") and not self._data.get("buying"):
|
|
25
|
+
raise ValidationError("At least one of Selling or Buying must be enabled")
|
|
26
|
+
|
|
27
|
+
rtype = self.rate_or_discount or "Discount Percentage"
|
|
28
|
+
if rtype == "Rate" and not flt(self.rate):
|
|
29
|
+
raise ValidationError("Rate is required when Rate type is selected")
|
|
30
|
+
if rtype == "Discount Percentage" and not flt(self.discount_percentage):
|
|
31
|
+
raise ValidationError("Discount Percentage is required")
|
|
32
|
+
if rtype == "Discount Amount" and not flt(self.discount_amount):
|
|
33
|
+
raise ValidationError("Discount Amount is required")
|
|
34
|
+
|
|
35
|
+
if self.valid_from and self.valid_upto:
|
|
36
|
+
if getdate(self.valid_from) > getdate(self.valid_upto):
|
|
37
|
+
raise ValidationError("Valid From cannot be after Valid Upto")
|
|
38
|
+
|
|
39
|
+
def apply_pricing_rules(doc):
|
|
40
|
+
"""Apply matching pricing rules to a transaction document.
|
|
41
|
+
|
|
42
|
+
Called during validate() of Quotation, SO, SI, PO, PI.
|
|
43
|
+
For each item, finds the best matching rule and applies it.
|
|
44
|
+
"""
|
|
45
|
+
db = get_db()
|
|
46
|
+
today = nowdate()
|
|
47
|
+
|
|
48
|
+
# Determine if selling or buying
|
|
49
|
+
is_selling = hasattr(doc, "customer") and doc.customer
|
|
50
|
+
is_buying = hasattr(doc, "supplier") and doc.supplier
|
|
51
|
+
|
|
52
|
+
for item in doc.get("items") or []:
|
|
53
|
+
item_code = item.get("item_code")
|
|
54
|
+
if not item_code:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
qty = flt(item.get("qty", 0))
|
|
58
|
+
|
|
59
|
+
# Query matching rules
|
|
60
|
+
rules = db.sql(
|
|
61
|
+
"""
|
|
62
|
+
SELECT * FROM "Pricing Rule"
|
|
63
|
+
WHERE item_code = ?
|
|
64
|
+
AND enabled = 1
|
|
65
|
+
AND (valid_from IS NULL OR valid_from = '' OR valid_from <= ?)
|
|
66
|
+
AND (valid_upto IS NULL OR valid_upto = '' OR valid_upto >= ?)
|
|
67
|
+
AND (min_qty = 0 OR min_qty <= ?)
|
|
68
|
+
ORDER BY priority DESC, min_qty DESC
|
|
69
|
+
LIMIT 1
|
|
70
|
+
""",
|
|
71
|
+
[item_code, today, today, qty],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if not rules:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
rule = rules[0]
|
|
78
|
+
|
|
79
|
+
# Check selling/buying applicability
|
|
80
|
+
if is_selling and not rule.get("selling"):
|
|
81
|
+
continue
|
|
82
|
+
if is_buying and not rule.get("buying"):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
rtype = rule.get("rate_or_discount", "Discount Percentage")
|
|
86
|
+
|
|
87
|
+
if rtype == "Rate":
|
|
88
|
+
item["rate"] = flt(rule["rate"])
|
|
89
|
+
item["price_list_rate"] = flt(rule["rate"])
|
|
90
|
+
item["discount_percentage"] = 0
|
|
91
|
+
item["discount_amount"] = 0
|
|
92
|
+
elif rtype == "Discount Percentage":
|
|
93
|
+
pct = flt(rule["discount_percentage"])
|
|
94
|
+
item["discount_percentage"] = pct
|
|
95
|
+
rate = flt(item.get("price_list_rate") or item.get("rate", 0))
|
|
96
|
+
item["rate"] = flt(rate * (1 - pct / 100), 2)
|
|
97
|
+
elif rtype == "Discount Amount":
|
|
98
|
+
amt = flt(rule["discount_amount"])
|
|
99
|
+
item["discount_amount"] = amt
|
|
100
|
+
rate = flt(item.get("price_list_rate") or item.get("rate", 0))
|
|
101
|
+
item["rate"] = flt(rate - amt, 2)
|
|
102
|
+
|
|
103
|
+
item["pricing_rule"] = rule.get("name")
|