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,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purchase Invoice.
|
|
3
|
+
|
|
4
|
+
Mirror image of Sales Invoice for the buying side:
|
|
5
|
+
Purchase Order -> Purchase Receipt -> **Purchase Invoice**
|
|
6
|
+
|
|
7
|
+
GL entries on submit:
|
|
8
|
+
Debit: Expense/Stock Account = net_amount per item
|
|
9
|
+
Debit: Tax Account = tax_amount per row (input tax)
|
|
10
|
+
Credit: Accounts Payable = grand_total
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from lambda_erp.model import Document
|
|
14
|
+
from lambda_erp.utils import _dict, flt, nowdate, add_days
|
|
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.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
|
|
19
|
+
from lambda_erp.stock.stock_ledger import (
|
|
20
|
+
make_sl_entries,
|
|
21
|
+
build_buy_side_sles,
|
|
22
|
+
reverse_stock_sles,
|
|
23
|
+
)
|
|
24
|
+
from lambda_erp.exceptions import ValidationError
|
|
25
|
+
|
|
26
|
+
class PurchaseInvoice(Document):
|
|
27
|
+
DOCTYPE = "Purchase Invoice"
|
|
28
|
+
CHILD_TABLES = {
|
|
29
|
+
"items": ("Purchase Invoice Item", None),
|
|
30
|
+
"taxes": ("Sales Taxes and Charges", None),
|
|
31
|
+
}
|
|
32
|
+
PREFIX = "PINV"
|
|
33
|
+
|
|
34
|
+
LINK_FIELDS = {
|
|
35
|
+
"supplier": "Supplier",
|
|
36
|
+
"company": "Company",
|
|
37
|
+
"credit_to": "Account",
|
|
38
|
+
}
|
|
39
|
+
CHILD_LINK_FIELDS = {
|
|
40
|
+
"items": {
|
|
41
|
+
"item_code": "Item",
|
|
42
|
+
"warehouse": "Warehouse",
|
|
43
|
+
"expense_account": "Account",
|
|
44
|
+
"cost_center": "Cost Center",
|
|
45
|
+
},
|
|
46
|
+
"taxes": {
|
|
47
|
+
"account_head": "Account",
|
|
48
|
+
"cost_center": "Cost Center",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
ACCOUNT_TYPE_CONSTRAINTS = {
|
|
52
|
+
"credit_to": {"account_type": "Payable"},
|
|
53
|
+
}
|
|
54
|
+
# items.expense_account intentionally omitted — it's legitimately routed
|
|
55
|
+
# to Expense (services), Asset/SIH (direct-receive stock), or Asset/SRBNB
|
|
56
|
+
# (PR→PI flow) depending on item type + update_stock.
|
|
57
|
+
|
|
58
|
+
def validate(self):
|
|
59
|
+
if not self.supplier:
|
|
60
|
+
raise ValidationError("Supplier is required")
|
|
61
|
+
if not self.get("items"):
|
|
62
|
+
raise ValidationError("At least one item is required")
|
|
63
|
+
if not self.posting_date:
|
|
64
|
+
self.posting_date = nowdate()
|
|
65
|
+
|
|
66
|
+
self._set_supplier_name()
|
|
67
|
+
self._set_missing_accounts()
|
|
68
|
+
self._set_item_defaults()
|
|
69
|
+
self._validate_stock_warehouses()
|
|
70
|
+
self._validate_no_double_receipt()
|
|
71
|
+
|
|
72
|
+
if self.is_return:
|
|
73
|
+
self._validate_return()
|
|
74
|
+
|
|
75
|
+
from lambda_erp.controllers.pricing_rule import apply_pricing_rules
|
|
76
|
+
apply_pricing_rules(self)
|
|
77
|
+
|
|
78
|
+
set_default_currency(self, "Supplier", "supplier")
|
|
79
|
+
|
|
80
|
+
calculate_taxes_and_totals(self)
|
|
81
|
+
|
|
82
|
+
if self.is_return:
|
|
83
|
+
self._validate_return_value()
|
|
84
|
+
|
|
85
|
+
self._data["outstanding_amount"] = flt(self.grand_total, 2)
|
|
86
|
+
|
|
87
|
+
if not self.due_date:
|
|
88
|
+
self._data["due_date"] = add_days(self.posting_date, 30)
|
|
89
|
+
|
|
90
|
+
def _set_supplier_name(self):
|
|
91
|
+
if not self.supplier_name and self.supplier:
|
|
92
|
+
db = get_db()
|
|
93
|
+
self.supplier_name = db.get_value("Supplier", self.supplier, "supplier_name")
|
|
94
|
+
|
|
95
|
+
def _set_missing_accounts(self):
|
|
96
|
+
"""Route each line's expense_account based on item + update_stock.
|
|
97
|
+
|
|
98
|
+
Three valid workflows:
|
|
99
|
+
1. PR -> PI (update_stock=0, stock item): line debits SRBNB to clear
|
|
100
|
+
the interim account opened by the Purchase Receipt.
|
|
101
|
+
2. PI with update_stock=1 (direct receive-and-bill, stock item): line
|
|
102
|
+
debits Stock In Hand directly (and SLE entries post below in
|
|
103
|
+
on_submit). There is no prior PR to clear.
|
|
104
|
+
3. Services / non-stock: line debits the default expense account.
|
|
105
|
+
"""
|
|
106
|
+
db = get_db()
|
|
107
|
+
if not self.company:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if not self.credit_to:
|
|
111
|
+
self._data["credit_to"] = db.get_value(
|
|
112
|
+
"Company", self.company, "default_payable_account"
|
|
113
|
+
)
|
|
114
|
+
default_expense = db.get_value("Company", self.company, "default_expense_account")
|
|
115
|
+
stock_received_account = db.get_value(
|
|
116
|
+
"Company", self.company, "stock_received_but_not_billed"
|
|
117
|
+
)
|
|
118
|
+
stock_in_hand_account = db.get_value(
|
|
119
|
+
"Company", self.company, "stock_in_hand_account"
|
|
120
|
+
)
|
|
121
|
+
default_cc = db.get_value("Company", self.company, "default_cost_center")
|
|
122
|
+
directly_receiving = flt(self.get("update_stock")) == 1
|
|
123
|
+
|
|
124
|
+
for item in self.get("items"):
|
|
125
|
+
if not item.get("expense_account"):
|
|
126
|
+
is_stock = 0
|
|
127
|
+
if item.get("item_code"):
|
|
128
|
+
is_stock = db.get_value("Item", item["item_code"], "is_stock_item") or 0
|
|
129
|
+
if is_stock and directly_receiving and stock_in_hand_account:
|
|
130
|
+
item["expense_account"] = stock_in_hand_account
|
|
131
|
+
elif is_stock and stock_received_account:
|
|
132
|
+
item["expense_account"] = stock_received_account
|
|
133
|
+
else:
|
|
134
|
+
item["expense_account"] = default_expense
|
|
135
|
+
if not item.get("cost_center"):
|
|
136
|
+
item["cost_center"] = default_cc
|
|
137
|
+
|
|
138
|
+
def _validate_stock_warehouses(self):
|
|
139
|
+
"""When update_stock=1, every stock-item line needs a warehouse so
|
|
140
|
+
the SLE has somewhere to put the received goods."""
|
|
141
|
+
if not flt(self.get("update_stock")):
|
|
142
|
+
return
|
|
143
|
+
db = get_db()
|
|
144
|
+
for item in self.get("items"):
|
|
145
|
+
if not item.get("item_code"):
|
|
146
|
+
continue
|
|
147
|
+
is_stock = db.get_value("Item", item["item_code"], "is_stock_item") or 0
|
|
148
|
+
if is_stock and not item.get("warehouse"):
|
|
149
|
+
raise ValidationError(
|
|
150
|
+
f"Warehouse is required for stock item {item['item_code']} "
|
|
151
|
+
f"when update_stock is checked"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _check_no_linked_payment_entry(self):
|
|
155
|
+
"""Mirror of SalesInvoice._check_no_linked_payment_entry — cancelling a
|
|
156
|
+
PI whose AP has been paid by a Payment Entry orphans the PE's
|
|
157
|
+
allocation and leaves AP/bank in an inconsistent state."""
|
|
158
|
+
db = get_db()
|
|
159
|
+
rows = db.sql(
|
|
160
|
+
'SELECT DISTINCT pe.name AS pe_name '
|
|
161
|
+
'FROM "Payment Entry Reference" per '
|
|
162
|
+
'JOIN "Payment Entry" pe ON pe.name = per.parent '
|
|
163
|
+
'WHERE pe.docstatus = 1 '
|
|
164
|
+
' AND per.reference_doctype = ? AND per.reference_name = ? LIMIT 1',
|
|
165
|
+
[self.DOCTYPE, self.name],
|
|
166
|
+
)
|
|
167
|
+
if rows:
|
|
168
|
+
raise ValidationError(
|
|
169
|
+
f"Cannot cancel {self.name}: Payment Entry {rows[0]['pe_name']} "
|
|
170
|
+
f"is already allocated against it. Cancel the Payment Entry first."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _validate_no_double_receipt(self):
|
|
174
|
+
"""Block update_stock=1 when the referenced Purchase Order already has
|
|
175
|
+
a Purchase Receipt for the line. Otherwise stock arrives twice: once
|
|
176
|
+
via the PR and again when this invoice submits. Returns are exempt."""
|
|
177
|
+
if not flt(self.get("update_stock")) or self.is_return:
|
|
178
|
+
return
|
|
179
|
+
db = get_db()
|
|
180
|
+
for item in self.get("items"):
|
|
181
|
+
po = item.get("purchase_order")
|
|
182
|
+
po_item = item.get("purchase_order_item")
|
|
183
|
+
if not (po or po_item):
|
|
184
|
+
continue
|
|
185
|
+
pr_rows = db.sql(
|
|
186
|
+
'SELECT pr.name AS pr_name '
|
|
187
|
+
'FROM "Purchase Receipt Item" pri '
|
|
188
|
+
'JOIN "Purchase Receipt" pr ON pr.name = pri.parent '
|
|
189
|
+
'WHERE pr.docstatus = 1 '
|
|
190
|
+
'AND (pri.against_purchase_order = ? OR pri.po_detail = ?) '
|
|
191
|
+
'AND pri.item_code = ? LIMIT 1',
|
|
192
|
+
[po or "", po_item or "", item.get("item_code")],
|
|
193
|
+
)
|
|
194
|
+
if pr_rows:
|
|
195
|
+
raise ValidationError(
|
|
196
|
+
f"Cannot submit with update_stock=1: item {item.get('item_code')} "
|
|
197
|
+
f"was already received via Purchase Receipt {pr_rows[0]['pr_name']}. "
|
|
198
|
+
f"Either uncheck update_stock (use this as a bill only, clearing "
|
|
199
|
+
f"SRBNB) or cancel the Purchase Receipt first."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _set_item_defaults(self):
|
|
203
|
+
db = get_db()
|
|
204
|
+
for item in self.get("items"):
|
|
205
|
+
if item.get("item_code") and not item.get("item_name"):
|
|
206
|
+
item_data = db.get_value(
|
|
207
|
+
"Item", item["item_code"],
|
|
208
|
+
["item_name", "description", "stock_uom", "standard_rate"]
|
|
209
|
+
)
|
|
210
|
+
if item_data:
|
|
211
|
+
item["item_name"] = item_data.item_name
|
|
212
|
+
item["description"] = item.get("description") or item_data.description
|
|
213
|
+
item["uom"] = item.get("uom") or item_data.stock_uom
|
|
214
|
+
if not item.get("rate"):
|
|
215
|
+
item["rate"] = flt(item_data.standard_rate)
|
|
216
|
+
|
|
217
|
+
def _validate_return(self):
|
|
218
|
+
"""Validate return-specific rules."""
|
|
219
|
+
if not self.return_against:
|
|
220
|
+
raise ValidationError("Return Against is required for a return invoice")
|
|
221
|
+
|
|
222
|
+
db = get_db()
|
|
223
|
+
original = db.get_value(self.DOCTYPE, self.return_against, ["name", "docstatus", "grand_total"])
|
|
224
|
+
if not original:
|
|
225
|
+
raise ValidationError(f"Original invoice {self.return_against} not found")
|
|
226
|
+
if original.docstatus != 1:
|
|
227
|
+
raise ValidationError(f"Original invoice {self.return_against} must be submitted")
|
|
228
|
+
|
|
229
|
+
# Aggregate already-returned qty per item across other submitted return
|
|
230
|
+
# invoices against the same original. Without this, the same item can be
|
|
231
|
+
# returned twice in separate debit notes, driving the original's
|
|
232
|
+
# outstanding negative and producing phantom AP balances.
|
|
233
|
+
already_returned: dict[str, float] = {}
|
|
234
|
+
prev_rows = db.sql(
|
|
235
|
+
"""SELECT pii.item_code, COALESCE(SUM(ABS(pii.qty)), 0) AS qty
|
|
236
|
+
FROM "Purchase Invoice Item" pii
|
|
237
|
+
JOIN "Purchase Invoice" pi ON pi.name = pii.parent
|
|
238
|
+
WHERE pi.return_against = ?
|
|
239
|
+
AND pi.docstatus = 1
|
|
240
|
+
AND pi.name != ?
|
|
241
|
+
GROUP BY pii.item_code""",
|
|
242
|
+
[self.return_against, self.name or ""],
|
|
243
|
+
)
|
|
244
|
+
for row in prev_rows:
|
|
245
|
+
already_returned[row["item_code"]] = flt(row["qty"])
|
|
246
|
+
|
|
247
|
+
original_doc = PurchaseInvoice.load(self.return_against)
|
|
248
|
+
original_items = {item["item_code"]: flt(item["qty"]) for item in original_doc.get("items")}
|
|
249
|
+
for item in self.get("items"):
|
|
250
|
+
orig_qty = original_items.get(item.get("item_code"), 0)
|
|
251
|
+
prev = already_returned.get(item.get("item_code"), 0)
|
|
252
|
+
return_qty = abs(flt(item.get("qty")))
|
|
253
|
+
remaining = max(0, orig_qty - prev)
|
|
254
|
+
if return_qty > remaining + 0.01:
|
|
255
|
+
hint = (
|
|
256
|
+
f"original qty {orig_qty}, already returned {prev}, "
|
|
257
|
+
f"remaining {remaining}"
|
|
258
|
+
if prev
|
|
259
|
+
else f"original qty {orig_qty}"
|
|
260
|
+
)
|
|
261
|
+
raise ValidationError(
|
|
262
|
+
f"Return qty ({return_qty}) for {item.get('item_code')} exceeds "
|
|
263
|
+
f"remaining returnable qty ({hint})"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _update_original_outstanding(self):
|
|
267
|
+
"""Reduce original invoice outstanding when a return is submitted."""
|
|
268
|
+
db = get_db()
|
|
269
|
+
current = flt(db.get_value(self.DOCTYPE, self.return_against, "outstanding_amount"))
|
|
270
|
+
reduction = abs(flt(self.grand_total, 2))
|
|
271
|
+
new_outstanding = max(flt(current - reduction, 2), 0)
|
|
272
|
+
db.set_value(self.DOCTYPE, self.return_against, "outstanding_amount", new_outstanding)
|
|
273
|
+
|
|
274
|
+
def _validate_return_value(self):
|
|
275
|
+
"""Mirror SalesInvoice._validate_return_value on the purchase side."""
|
|
276
|
+
db = get_db()
|
|
277
|
+
original_total = abs(
|
|
278
|
+
flt(db.get_value(self.DOCTYPE, self.return_against, "grand_total"), 2)
|
|
279
|
+
)
|
|
280
|
+
prev_rows = db.sql(
|
|
281
|
+
"""SELECT COALESCE(SUM(ABS(grand_total)), 0) AS total
|
|
282
|
+
FROM "Purchase Invoice"
|
|
283
|
+
WHERE return_against = ?
|
|
284
|
+
AND docstatus = 1
|
|
285
|
+
AND name != ?""",
|
|
286
|
+
[self.return_against, self.name or ""],
|
|
287
|
+
)
|
|
288
|
+
already_returned = flt(prev_rows[0]["total"]) if prev_rows else 0
|
|
289
|
+
remaining_value = max(0, flt(original_total - already_returned, 2))
|
|
290
|
+
this_value = abs(flt(self.grand_total, 2))
|
|
291
|
+
if this_value > remaining_value + 0.01:
|
|
292
|
+
raise ValidationError(
|
|
293
|
+
f"Return total ({this_value}) exceeds remaining returnable value "
|
|
294
|
+
f"(original {original_total}, already returned {already_returned}, "
|
|
295
|
+
f"remaining {remaining_value})"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _reverse_original_outstanding(self):
|
|
299
|
+
"""Restore original invoice outstanding when a return is cancelled."""
|
|
300
|
+
db = get_db()
|
|
301
|
+
current = flt(db.get_value(self.DOCTYPE, self.return_against, "outstanding_amount"))
|
|
302
|
+
restoration = abs(flt(self.grand_total, 2))
|
|
303
|
+
db.set_value(self.DOCTYPE, self.return_against, "outstanding_amount", flt(current + restoration, 2))
|
|
304
|
+
|
|
305
|
+
def on_submit(self):
|
|
306
|
+
"""Post GL entries.
|
|
307
|
+
|
|
308
|
+
Accounting entry for a Purchase Invoice:
|
|
309
|
+
Debit: Expense Account (per item) = net_amount
|
|
310
|
+
Debit: Tax Account (input tax) = tax_amount
|
|
311
|
+
Credit: Accounts Payable (supplier) = grand_total
|
|
312
|
+
|
|
313
|
+
When update_stock=1, this invoice also receives the goods — stock
|
|
314
|
+
items get SLE rows so Bin updates, and their expense_account was
|
|
315
|
+
routed to Stock In Hand in _set_missing_accounts so the existing
|
|
316
|
+
GL loop naturally books Dr Stock In Hand for those lines.
|
|
317
|
+
"""
|
|
318
|
+
if flt(self.get("update_stock")):
|
|
319
|
+
sl_entries = self._get_stock_sl_entries()
|
|
320
|
+
if sl_entries:
|
|
321
|
+
make_sl_entries(sl_entries)
|
|
322
|
+
|
|
323
|
+
gl_entries = self._get_gl_entries()
|
|
324
|
+
# AP/expense/tax (and the SIH leg for direct-receive stock items) are
|
|
325
|
+
# built in document currency; convert to base. The buy-side SLEs above
|
|
326
|
+
# are valued in base too (build_buy_side_sles applies conversion_rate),
|
|
327
|
+
# so the Stock-In-Hand GL matches the stock-ledger value.
|
|
328
|
+
to_base_currency(gl_entries, self.get("conversion_rate"))
|
|
329
|
+
make_gl_entries(gl_entries)
|
|
330
|
+
self._update_purchase_order_billing()
|
|
331
|
+
|
|
332
|
+
if self.is_return and self.return_against:
|
|
333
|
+
self._update_original_outstanding()
|
|
334
|
+
|
|
335
|
+
def on_cancel(self):
|
|
336
|
+
self._check_no_linked_payment_entry()
|
|
337
|
+
|
|
338
|
+
if flt(self.get("update_stock")):
|
|
339
|
+
reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
|
|
340
|
+
if reversed_sles:
|
|
341
|
+
make_sl_entries(reversed_sles, allow_negative_stock=True)
|
|
342
|
+
|
|
343
|
+
make_reverse_gl_entries(
|
|
344
|
+
voucher_type=self.DOCTYPE,
|
|
345
|
+
voucher_no=self.name,
|
|
346
|
+
)
|
|
347
|
+
db = get_db()
|
|
348
|
+
db.set_value(self.DOCTYPE, self.name, "outstanding_amount", 0)
|
|
349
|
+
|
|
350
|
+
if self.is_return and self.return_against:
|
|
351
|
+
self._reverse_original_outstanding()
|
|
352
|
+
|
|
353
|
+
self._update_purchase_order_billing(cancel=True)
|
|
354
|
+
|
|
355
|
+
def _get_stock_sl_entries(self):
|
|
356
|
+
"""SLEs for direct-receive PIs. Only stock items contribute; services
|
|
357
|
+
stay off the stock ledger. See stock_ledger.build_buy_side_sles for
|
|
358
|
+
how incoming cost is set from the supplier rate (not moving-average).
|
|
359
|
+
"""
|
|
360
|
+
db = get_db()
|
|
361
|
+
stock_items = [
|
|
362
|
+
item
|
|
363
|
+
for item in self.get("items")
|
|
364
|
+
if item.get("item_code")
|
|
365
|
+
and item.get("warehouse")
|
|
366
|
+
and (db.get_value("Item", item["item_code"], "is_stock_item") or 0)
|
|
367
|
+
]
|
|
368
|
+
return build_buy_side_sles(self, stock_items)
|
|
369
|
+
|
|
370
|
+
def _get_gl_entries(self):
|
|
371
|
+
"""Build GL entry map - mirror image of Sales Invoice."""
|
|
372
|
+
gl_entries = []
|
|
373
|
+
|
|
374
|
+
# 1. Credit: Accounts Payable (supplier)
|
|
375
|
+
gl_entries.append(
|
|
376
|
+
_dict(
|
|
377
|
+
account=self.credit_to,
|
|
378
|
+
party_type="Supplier",
|
|
379
|
+
party=self.supplier,
|
|
380
|
+
credit=flt(self.grand_total, 2),
|
|
381
|
+
credit_in_account_currency=flt(self.grand_total, 2),
|
|
382
|
+
debit=0,
|
|
383
|
+
debit_in_account_currency=0,
|
|
384
|
+
against_voucher_type=self.DOCTYPE,
|
|
385
|
+
against_voucher=self.name,
|
|
386
|
+
voucher_type=self.DOCTYPE,
|
|
387
|
+
voucher_no=self.name,
|
|
388
|
+
posting_date=self.posting_date,
|
|
389
|
+
company=self.company,
|
|
390
|
+
remarks=self.remarks or f"Purchase Invoice {self.name}",
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# 2. Debit: Expense/Stock accounts (per item)
|
|
395
|
+
expense_accounts = {}
|
|
396
|
+
for item in self.get("items"):
|
|
397
|
+
account = item.get("expense_account")
|
|
398
|
+
if account not in expense_accounts:
|
|
399
|
+
expense_accounts[account] = 0
|
|
400
|
+
expense_accounts[account] += flt(item.get("net_amount", 0))
|
|
401
|
+
|
|
402
|
+
for account, amount in expense_accounts.items():
|
|
403
|
+
gl_entries.append(
|
|
404
|
+
_dict(
|
|
405
|
+
account=account,
|
|
406
|
+
debit=flt(amount, 2),
|
|
407
|
+
debit_in_account_currency=flt(amount, 2),
|
|
408
|
+
credit=0,
|
|
409
|
+
credit_in_account_currency=0,
|
|
410
|
+
cost_center=self.get("items")[0].get("cost_center") if self.get("items") else None,
|
|
411
|
+
voucher_type=self.DOCTYPE,
|
|
412
|
+
voucher_no=self.name,
|
|
413
|
+
posting_date=self.posting_date,
|
|
414
|
+
company=self.company,
|
|
415
|
+
remarks=f"Expense from {self.name}",
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# 3. Debit: Tax accounts (input tax / tax credit)
|
|
420
|
+
for tax in self.get("taxes") or []:
|
|
421
|
+
if flt(tax.get("tax_amount")):
|
|
422
|
+
gl_entries.append(
|
|
423
|
+
_dict(
|
|
424
|
+
account=tax.get("account_head"),
|
|
425
|
+
debit=flt(tax["tax_amount"], 2),
|
|
426
|
+
debit_in_account_currency=flt(tax["tax_amount"], 2),
|
|
427
|
+
credit=0,
|
|
428
|
+
credit_in_account_currency=0,
|
|
429
|
+
voucher_type=self.DOCTYPE,
|
|
430
|
+
voucher_no=self.name,
|
|
431
|
+
posting_date=self.posting_date,
|
|
432
|
+
company=self.company,
|
|
433
|
+
remarks=tax.get("description") or f"Tax: {tax.get('account_head')}",
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return gl_entries
|
|
438
|
+
|
|
439
|
+
def _update_purchase_order_billing(self, cancel=False):
|
|
440
|
+
db = get_db()
|
|
441
|
+
for item in self.get("items"):
|
|
442
|
+
if item.get("purchase_order") and item.get("purchase_order_item"):
|
|
443
|
+
billed_qty = flt(item.get("qty"))
|
|
444
|
+
if cancel:
|
|
445
|
+
billed_qty = -billed_qty
|
|
446
|
+
current = db.get_value(
|
|
447
|
+
"Purchase Order Item", item["purchase_order_item"], "billed_qty"
|
|
448
|
+
) or 0
|
|
449
|
+
db.set_value(
|
|
450
|
+
"Purchase Order Item", item["purchase_order_item"],
|
|
451
|
+
"billed_qty", flt(current) + billed_qty
|
|
452
|
+
)
|
|
453
|
+
db.commit()
|
|
454
|
+
|
|
455
|
+
def make_purchase_return(pinv_name):
|
|
456
|
+
"""Create a Debit Note (return Purchase Invoice) from an existing Purchase Invoice."""
|
|
457
|
+
db = get_db()
|
|
458
|
+
original = PurchaseInvoice.load(pinv_name)
|
|
459
|
+
|
|
460
|
+
if original.docstatus != 1:
|
|
461
|
+
raise ValidationError("Purchase Invoice must be submitted before creating a return")
|
|
462
|
+
if original.is_return:
|
|
463
|
+
raise ValidationError("Cannot create a return against a return")
|
|
464
|
+
|
|
465
|
+
return_inv = PurchaseInvoice(
|
|
466
|
+
supplier=original.supplier,
|
|
467
|
+
company=original.company,
|
|
468
|
+
currency=original.get("currency") or "USD",
|
|
469
|
+
conversion_rate=original.get("conversion_rate") or 1.0,
|
|
470
|
+
posting_date=nowdate(),
|
|
471
|
+
credit_to=original.credit_to,
|
|
472
|
+
is_return=1,
|
|
473
|
+
return_against=original.name,
|
|
474
|
+
# Direct-receive PI (update_stock=1) put goods into stock on submit;
|
|
475
|
+
# the return must move them back out. Without this, AP reverses but
|
|
476
|
+
# the inventory just sits there.
|
|
477
|
+
update_stock=flt(original.get("update_stock")) or 0,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
for item in original.get("items"):
|
|
481
|
+
return_inv.append("items", _dict(
|
|
482
|
+
item_code=item.get("item_code"),
|
|
483
|
+
item_name=item.get("item_name"),
|
|
484
|
+
description=item.get("description"),
|
|
485
|
+
qty=-flt(item.get("qty")),
|
|
486
|
+
uom=item.get("uom"),
|
|
487
|
+
rate=flt(item.get("rate")),
|
|
488
|
+
expense_account=item.get("expense_account"),
|
|
489
|
+
cost_center=item.get("cost_center"),
|
|
490
|
+
warehouse=item.get("warehouse"),
|
|
491
|
+
purchase_order=item.get("purchase_order"),
|
|
492
|
+
purchase_order_item=item.get("purchase_order_item"),
|
|
493
|
+
))
|
|
494
|
+
|
|
495
|
+
for tax in original.get("taxes") or []:
|
|
496
|
+
return_inv.append("taxes", _dict(
|
|
497
|
+
charge_type=tax.get("charge_type"),
|
|
498
|
+
account_head=tax.get("account_head"),
|
|
499
|
+
description=tax.get("description"),
|
|
500
|
+
rate=flt(tax.get("rate")),
|
|
501
|
+
tax_amount=0,
|
|
502
|
+
))
|
|
503
|
+
|
|
504
|
+
return return_inv
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Period-end revaluation of open foreign-currency monetary balances.
|
|
2
|
+
|
|
3
|
+
Open foreign receivables, payables, and bank/cash balances are carried at the
|
|
4
|
+
historical rate(s) they were booked at. At period end their base value has
|
|
5
|
+
drifted from today's rate; `run_period_revaluation` restates them to the
|
|
6
|
+
closing rate and books the difference as **unrealized** FX gain/loss.
|
|
7
|
+
|
|
8
|
+
Principles (see docs/multicurrency-phase-4c.md):
|
|
9
|
+
- Never edit posted entries — revaluation posts new GL, and an auto-reversal
|
|
10
|
+
dated the next day backs it out so it doesn't double-count once the balance
|
|
11
|
+
settles (and realized FX is recognized then).
|
|
12
|
+
- Only the base value moves; the account-currency balance is unchanged, so the
|
|
13
|
+
*_in_account_currency amounts on revaluation entries are 0.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from lambda_erp.utils import _dict, flt, nowdate, add_days, new_name
|
|
17
|
+
from lambda_erp.database import get_db
|
|
18
|
+
from lambda_erp.controllers.currency import get_exchange_rate
|
|
19
|
+
from lambda_erp.accounting.general_ledger import make_gl_entries, get_account_balances
|
|
20
|
+
from lambda_erp.exceptions import ValidationError
|
|
21
|
+
|
|
22
|
+
# A delta smaller than this (in base currency) isn't worth a GL line.
|
|
23
|
+
_EPSILON = 0.005
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def collect_revaluation_lines(company, date):
|
|
27
|
+
"""Return the per-balance revaluation breakdown (no posting).
|
|
28
|
+
|
|
29
|
+
Each line: account, currency, kind, is_asset, foreign, current_base,
|
|
30
|
+
closing_base, unrealized (= closing_base - current_base).
|
|
31
|
+
"""
|
|
32
|
+
db = get_db()
|
|
33
|
+
base_ccy = db.get_value("Company", company, "default_currency") or "USD"
|
|
34
|
+
ar_account = db.get_value("Company", company, "default_receivable_account")
|
|
35
|
+
ap_account = db.get_value("Company", company, "default_payable_account")
|
|
36
|
+
|
|
37
|
+
lines = []
|
|
38
|
+
|
|
39
|
+
def _aggregate_invoices(doctypes):
|
|
40
|
+
by_ccy = {}
|
|
41
|
+
for doctype in doctypes:
|
|
42
|
+
rows = db.sql(
|
|
43
|
+
f'SELECT currency, conversion_rate, outstanding_amount FROM "{doctype}" '
|
|
44
|
+
f'WHERE company = ? AND docstatus = 1 AND outstanding_amount != 0 '
|
|
45
|
+
f'AND currency IS NOT NULL AND currency != ?',
|
|
46
|
+
[company, base_ccy],
|
|
47
|
+
)
|
|
48
|
+
for r in rows:
|
|
49
|
+
agg = by_ccy.setdefault(r["currency"], [0.0, 0.0])
|
|
50
|
+
out = flt(r["outstanding_amount"])
|
|
51
|
+
agg[0] += out
|
|
52
|
+
agg[1] += out * (flt(r["conversion_rate"]) or 1.0)
|
|
53
|
+
return by_ccy
|
|
54
|
+
|
|
55
|
+
def _add_party_lines(by_ccy, account, kind, is_asset):
|
|
56
|
+
for ccy, (out_sum, current_base) in by_ccy.items():
|
|
57
|
+
closing = get_exchange_rate(ccy, base_ccy, date)
|
|
58
|
+
closing_base = flt(out_sum * closing, 2)
|
|
59
|
+
lines.append(_dict(
|
|
60
|
+
account=account, currency=ccy, kind=kind, is_asset=is_asset,
|
|
61
|
+
foreign=flt(out_sum, 2), current_base=flt(current_base, 2),
|
|
62
|
+
closing_base=closing_base, unrealized=flt(closing_base - flt(current_base, 2), 2),
|
|
63
|
+
))
|
|
64
|
+
|
|
65
|
+
_add_party_lines(_aggregate_invoices(["Sales Invoice", "POS Invoice"]),
|
|
66
|
+
ar_account, "receivable", True)
|
|
67
|
+
_add_party_lines(_aggregate_invoices(["Purchase Invoice"]),
|
|
68
|
+
ap_account, "payable", False)
|
|
69
|
+
|
|
70
|
+
# Foreign bank / cash accounts: the whole balance is open.
|
|
71
|
+
accts = db.sql(
|
|
72
|
+
'SELECT name, account_currency FROM "Account" '
|
|
73
|
+
'WHERE company = ? AND is_group = 0 AND account_currency IS NOT NULL '
|
|
74
|
+
"AND account_currency != ? AND account_type IN ('Bank', 'Cash')",
|
|
75
|
+
[company, base_ccy],
|
|
76
|
+
)
|
|
77
|
+
for a in accts:
|
|
78
|
+
base_bal, ccy_bal = get_account_balances(a["name"], company)
|
|
79
|
+
if abs(ccy_bal) < _EPSILON:
|
|
80
|
+
continue
|
|
81
|
+
closing = get_exchange_rate(a["account_currency"], base_ccy, date)
|
|
82
|
+
closing_base = flt(ccy_bal * closing, 2)
|
|
83
|
+
lines.append(_dict(
|
|
84
|
+
account=a["name"], currency=a["account_currency"], kind="cash", is_asset=True,
|
|
85
|
+
foreign=flt(ccy_bal, 2), current_base=flt(base_bal, 2),
|
|
86
|
+
closing_base=closing_base, unrealized=flt(closing_base - flt(base_bal, 2), 2),
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
return lines
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_entries(lines, fx_account, company, date, voucher_no, swap):
|
|
93
|
+
"""Control leg moves the base value in the account's natural direction
|
|
94
|
+
(asset -> debit on a gain); the Unrealized FX account is the contra.
|
|
95
|
+
Account-currency amounts stay 0 — only the base translation changes.
|
|
96
|
+
Negative deltas are left negative; make_gl_entries toggles them."""
|
|
97
|
+
entries = []
|
|
98
|
+
for ln in lines:
|
|
99
|
+
d = flt(ln["unrealized"], 2)
|
|
100
|
+
if abs(d) < _EPSILON:
|
|
101
|
+
continue
|
|
102
|
+
ctrl_debit, ctrl_credit = (d, 0) if ln["is_asset"] else (0, d)
|
|
103
|
+
fx_debit, fx_credit = (0, d) if ln["is_asset"] else (d, 0)
|
|
104
|
+
if swap:
|
|
105
|
+
ctrl_debit, ctrl_credit = ctrl_credit, ctrl_debit
|
|
106
|
+
fx_debit, fx_credit = fx_credit, fx_debit
|
|
107
|
+
remark = (f"{'Reversal of ' if swap else ''}unrealized FX revaluation "
|
|
108
|
+
f"{ln['currency']} ({ln['kind']})")
|
|
109
|
+
entries.append(_dict(
|
|
110
|
+
account=ln["account"], debit=ctrl_debit, credit=ctrl_credit,
|
|
111
|
+
debit_in_account_currency=0, credit_in_account_currency=0,
|
|
112
|
+
voucher_type="Period Revaluation", voucher_no=voucher_no,
|
|
113
|
+
posting_date=date, company=company, remarks=remark,
|
|
114
|
+
))
|
|
115
|
+
entries.append(_dict(
|
|
116
|
+
account=fx_account, debit=fx_debit, credit=fx_credit,
|
|
117
|
+
debit_in_account_currency=0, credit_in_account_currency=0,
|
|
118
|
+
voucher_type="Period Revaluation", voucher_no=voucher_no,
|
|
119
|
+
posting_date=date, company=company, remarks=remark,
|
|
120
|
+
))
|
|
121
|
+
return entries
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_period_revaluation(company, date=None, *, post=True):
|
|
125
|
+
"""Restate open foreign balances to the closing rate at `date`.
|
|
126
|
+
|
|
127
|
+
Posts a balanced revaluation voucher dated `date` plus an auto-reversal
|
|
128
|
+
dated the next day, then returns a result dict (breakdown + what was
|
|
129
|
+
posted + the net P&L impact). With post=False it's a dry run — the
|
|
130
|
+
breakdown only, nothing posted. Raises if a foreign balance exists but no
|
|
131
|
+
Unrealized Exchange Gain/Loss account is configured.
|
|
132
|
+
"""
|
|
133
|
+
db = get_db()
|
|
134
|
+
on_date = date or nowdate()
|
|
135
|
+
base_ccy = db.get_value("Company", company, "default_currency") or "USD"
|
|
136
|
+
lines = collect_revaluation_lines(company, on_date)
|
|
137
|
+
postable = [ln for ln in lines if abs(flt(ln["unrealized"], 2)) >= _EPSILON]
|
|
138
|
+
|
|
139
|
+
# Net P&L impact in base currency: a gain on an asset and a (sign-flipped)
|
|
140
|
+
# loss on a liability. Positive = net unrealized gain.
|
|
141
|
+
net_pl = flt(sum(
|
|
142
|
+
(ln["unrealized"] if ln["is_asset"] else -ln["unrealized"]) for ln in lines
|
|
143
|
+
), 2)
|
|
144
|
+
|
|
145
|
+
result = {
|
|
146
|
+
"company": company,
|
|
147
|
+
"date": on_date,
|
|
148
|
+
"base_currency": base_ccy,
|
|
149
|
+
"lines": lines,
|
|
150
|
+
"net_unrealized_pl": net_pl,
|
|
151
|
+
"posted": False,
|
|
152
|
+
"voucher_no": None,
|
|
153
|
+
"reversal_voucher_no": None,
|
|
154
|
+
"reversal_date": None,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if post and postable:
|
|
158
|
+
fx_account = db.get_value("Company", company, "default_unrealized_exchange_account")
|
|
159
|
+
if not fx_account:
|
|
160
|
+
raise ValidationError(
|
|
161
|
+
"No Unrealized Exchange Gain/Loss account is configured on the company; "
|
|
162
|
+
"cannot post the period revaluation."
|
|
163
|
+
)
|
|
164
|
+
reval_no = new_name("REVAL")
|
|
165
|
+
reversal_no = new_name("REVAL")
|
|
166
|
+
next_date = add_days(on_date, 1).isoformat()
|
|
167
|
+
make_gl_entries(_build_entries(postable, fx_account, company, on_date, reval_no, swap=False))
|
|
168
|
+
make_gl_entries(_build_entries(postable, fx_account, company, next_date, reversal_no, swap=True))
|
|
169
|
+
result.update(posted=True, voucher_no=reval_no,
|
|
170
|
+
reversal_voucher_no=reversal_no, reversal_date=next_date)
|
|
171
|
+
|
|
172
|
+
return result
|