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
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delivery Note.
|
|
3
|
+
|
|
4
|
+
Delivery Note records goods shipped to a customer from a Sales Order:
|
|
5
|
+
Sales Order -> Delivery Note -> Sales Invoice
|
|
6
|
+
|
|
7
|
+
Key behaviors:
|
|
8
|
+
- Creates Stock Ledger Entries (stock OUT from warehouse)
|
|
9
|
+
- Creates GL entries (Dr: COGS, Cr: Stock In Hand)
|
|
10
|
+
- Updates delivered_qty on the linked Sales Order
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from lambda_erp.model import Document
|
|
14
|
+
from lambda_erp.utils import _dict, flt, 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
|
+
from lambda_erp.stock.stock_ledger import (
|
|
20
|
+
make_sl_entries,
|
|
21
|
+
build_sell_side_sles,
|
|
22
|
+
build_cost_basis_gl,
|
|
23
|
+
reverse_stock_sles,
|
|
24
|
+
)
|
|
25
|
+
from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries
|
|
26
|
+
|
|
27
|
+
class DeliveryNote(Document):
|
|
28
|
+
DOCTYPE = "Delivery Note"
|
|
29
|
+
CHILD_TABLES = {
|
|
30
|
+
"items": ("Delivery Note Item", None),
|
|
31
|
+
"taxes": ("Sales Taxes and Charges", None),
|
|
32
|
+
}
|
|
33
|
+
PREFIX = "DN"
|
|
34
|
+
|
|
35
|
+
LINK_FIELDS = {
|
|
36
|
+
"customer": "Customer",
|
|
37
|
+
"company": "Company",
|
|
38
|
+
}
|
|
39
|
+
CHILD_LINK_FIELDS = {
|
|
40
|
+
"items": {
|
|
41
|
+
"item_code": "Item",
|
|
42
|
+
"warehouse": "Warehouse",
|
|
43
|
+
},
|
|
44
|
+
"taxes": {
|
|
45
|
+
"account_head": "Account",
|
|
46
|
+
"cost_center": "Cost Center",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def validate(self):
|
|
51
|
+
if not self.customer:
|
|
52
|
+
raise ValidationError("Customer is required")
|
|
53
|
+
if not self.get("items"):
|
|
54
|
+
raise ValidationError("At least one item is required")
|
|
55
|
+
if not self.posting_date:
|
|
56
|
+
self.posting_date = nowdate()
|
|
57
|
+
|
|
58
|
+
self._set_customer_name()
|
|
59
|
+
self._set_item_defaults()
|
|
60
|
+
|
|
61
|
+
if self.is_return:
|
|
62
|
+
self._validate_return()
|
|
63
|
+
|
|
64
|
+
set_default_currency(self, "Customer", "customer")
|
|
65
|
+
calculate_taxes_and_totals(self)
|
|
66
|
+
self._set_status()
|
|
67
|
+
|
|
68
|
+
def _set_customer_name(self):
|
|
69
|
+
if not self.customer_name and self.customer:
|
|
70
|
+
db = get_db()
|
|
71
|
+
self.customer_name = db.get_value("Customer", self.customer, "customer_name")
|
|
72
|
+
|
|
73
|
+
def _set_item_defaults(self):
|
|
74
|
+
db = get_db()
|
|
75
|
+
for item in self.get("items"):
|
|
76
|
+
if item.get("item_code") and not item.get("item_name"):
|
|
77
|
+
item_data = db.get_value(
|
|
78
|
+
"Item", item["item_code"],
|
|
79
|
+
["item_name", "description", "stock_uom", "standard_rate"]
|
|
80
|
+
)
|
|
81
|
+
if item_data:
|
|
82
|
+
item["item_name"] = item_data.item_name
|
|
83
|
+
item["description"] = item.get("description") or item_data.description
|
|
84
|
+
item["uom"] = item.get("uom") or item_data.stock_uom
|
|
85
|
+
if not item.get("rate"):
|
|
86
|
+
item["rate"] = flt(item_data.standard_rate)
|
|
87
|
+
|
|
88
|
+
def _validate_return(self):
|
|
89
|
+
"""Validate return-specific rules."""
|
|
90
|
+
if not self.return_against:
|
|
91
|
+
raise ValidationError("Return Against is required for a return Delivery Note")
|
|
92
|
+
|
|
93
|
+
db = get_db()
|
|
94
|
+
original = db.get_value(self.DOCTYPE, self.return_against, ["name", "docstatus"])
|
|
95
|
+
if not original:
|
|
96
|
+
raise ValidationError(f"Original Delivery Note {self.return_against} not found")
|
|
97
|
+
if original.docstatus != 1:
|
|
98
|
+
raise ValidationError(f"Original Delivery Note {self.return_against} must be submitted")
|
|
99
|
+
|
|
100
|
+
original_doc = DeliveryNote.load(self.return_against)
|
|
101
|
+
original_items = {item["item_code"]: flt(item["qty"]) for item in original_doc.get("items")}
|
|
102
|
+
for item in self.get("items"):
|
|
103
|
+
orig_qty = original_items.get(item.get("item_code"), 0)
|
|
104
|
+
return_qty = abs(flt(item.get("qty")))
|
|
105
|
+
if return_qty > orig_qty:
|
|
106
|
+
raise ValidationError(
|
|
107
|
+
f"Return qty ({return_qty}) for {item.get('item_code')} exceeds "
|
|
108
|
+
f"original qty ({orig_qty})"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _set_status(self):
|
|
112
|
+
if self.docstatus == 0:
|
|
113
|
+
self._data["status"] = "Draft"
|
|
114
|
+
elif self.docstatus == 2:
|
|
115
|
+
self._data["status"] = "Cancelled"
|
|
116
|
+
elif self.docstatus == 1:
|
|
117
|
+
if flt(self.per_billed) >= 100:
|
|
118
|
+
self._data["status"] = "Completed"
|
|
119
|
+
else:
|
|
120
|
+
self._data["status"] = "To Bill"
|
|
121
|
+
|
|
122
|
+
def on_submit(self):
|
|
123
|
+
sl_entries = self._get_sl_entries()
|
|
124
|
+
make_sl_entries(sl_entries)
|
|
125
|
+
|
|
126
|
+
gl_entries = self._get_gl_entries()
|
|
127
|
+
if gl_entries:
|
|
128
|
+
make_gl_entries(gl_entries)
|
|
129
|
+
|
|
130
|
+
self._update_sales_order_delivered()
|
|
131
|
+
|
|
132
|
+
def on_cancel(self):
|
|
133
|
+
reversed_sles = reverse_stock_sles(self._get_sl_entries())
|
|
134
|
+
if reversed_sles:
|
|
135
|
+
make_sl_entries(reversed_sles, allow_negative_stock=True)
|
|
136
|
+
|
|
137
|
+
make_reverse_gl_entries(
|
|
138
|
+
voucher_type=self.DOCTYPE,
|
|
139
|
+
voucher_no=self.name,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
self._update_sales_order_delivered(cancel=True)
|
|
143
|
+
|
|
144
|
+
def _get_sl_entries(self):
|
|
145
|
+
return build_sell_side_sles(self, self.get("items"))
|
|
146
|
+
|
|
147
|
+
def _get_gl_entries(self):
|
|
148
|
+
return build_cost_basis_gl(self, remarks=f"Delivery Note {self.name}")
|
|
149
|
+
|
|
150
|
+
def _update_sales_order_delivered(self, cancel=False):
|
|
151
|
+
db = get_db()
|
|
152
|
+
updated_sos = set()
|
|
153
|
+
so_details = set()
|
|
154
|
+
for item in self.get("items"):
|
|
155
|
+
if item.get("against_sales_order"):
|
|
156
|
+
updated_sos.add(item["against_sales_order"])
|
|
157
|
+
if item.get("so_detail"):
|
|
158
|
+
so_details.add(item["so_detail"])
|
|
159
|
+
|
|
160
|
+
for so_detail in so_details:
|
|
161
|
+
result = db.sql(
|
|
162
|
+
"""SELECT COALESCE(SUM(qty), 0) as total_delivered
|
|
163
|
+
FROM "Delivery Note Item"
|
|
164
|
+
WHERE so_detail = ?
|
|
165
|
+
AND parent IN (
|
|
166
|
+
SELECT name FROM "Delivery Note" WHERE docstatus = 1
|
|
167
|
+
)""",
|
|
168
|
+
[so_detail],
|
|
169
|
+
)
|
|
170
|
+
delivered = flt(result[0]["total_delivered"]) if result else 0
|
|
171
|
+
db.set_value("Sales Order Item", so_detail, "delivered_qty", delivered)
|
|
172
|
+
|
|
173
|
+
for so_name in updated_sos:
|
|
174
|
+
from lambda_erp.selling.sales_order import SalesOrder
|
|
175
|
+
so = SalesOrder.load(so_name)
|
|
176
|
+
so.update_delivery_status()
|
|
177
|
+
|
|
178
|
+
def make_delivery_note(sales_order_name):
|
|
179
|
+
"""Convert a Sales Order into a Delivery Note."""
|
|
180
|
+
from lambda_erp.selling.sales_order import SalesOrder
|
|
181
|
+
|
|
182
|
+
so = SalesOrder.load(sales_order_name)
|
|
183
|
+
if so.docstatus != 1:
|
|
184
|
+
raise ValidationError("Sales Order must be submitted before creating Delivery Note")
|
|
185
|
+
|
|
186
|
+
dn = DeliveryNote(
|
|
187
|
+
customer=so.customer,
|
|
188
|
+
customer_name=so.customer_name,
|
|
189
|
+
company=so.company,
|
|
190
|
+
currency=so.currency,
|
|
191
|
+
conversion_rate=so.conversion_rate,
|
|
192
|
+
posting_date=nowdate(),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
for item in so.get("items"):
|
|
196
|
+
undelivered = flt(item.get("qty")) - flt(item.get("delivered_qty"))
|
|
197
|
+
if undelivered <= 0:
|
|
198
|
+
continue
|
|
199
|
+
dn.append("items", _dict(
|
|
200
|
+
item_code=item.get("item_code"),
|
|
201
|
+
item_name=item.get("item_name"),
|
|
202
|
+
description=item.get("description"),
|
|
203
|
+
qty=undelivered,
|
|
204
|
+
uom=item.get("uom"),
|
|
205
|
+
rate=item.get("rate"),
|
|
206
|
+
warehouse=item.get("warehouse"),
|
|
207
|
+
against_sales_order=so.name,
|
|
208
|
+
so_detail=item.get("name"),
|
|
209
|
+
))
|
|
210
|
+
|
|
211
|
+
for tax in so.get("taxes") or []:
|
|
212
|
+
dn.append("taxes", _dict(
|
|
213
|
+
charge_type=tax.get("charge_type"),
|
|
214
|
+
account_head=tax.get("account_head"),
|
|
215
|
+
description=tax.get("description"),
|
|
216
|
+
rate=tax.get("rate"),
|
|
217
|
+
tax_amount=0,
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
return dn
|
|
221
|
+
|
|
222
|
+
def make_delivery_return(dn_name):
|
|
223
|
+
"""Create a return Delivery Note (stock back in) from an existing Delivery Note."""
|
|
224
|
+
original = DeliveryNote.load(dn_name)
|
|
225
|
+
|
|
226
|
+
if original.docstatus != 1:
|
|
227
|
+
raise ValidationError("Delivery Note must be submitted before creating a return")
|
|
228
|
+
if original.is_return:
|
|
229
|
+
raise ValidationError("Cannot create a return against a return")
|
|
230
|
+
|
|
231
|
+
return_dn = DeliveryNote(
|
|
232
|
+
customer=original.customer,
|
|
233
|
+
company=original.company,
|
|
234
|
+
currency=original.get("currency") or "USD",
|
|
235
|
+
conversion_rate=original.get("conversion_rate") or 1.0,
|
|
236
|
+
posting_date=nowdate(),
|
|
237
|
+
is_return=1,
|
|
238
|
+
return_against=original.name,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
for item in original.get("items"):
|
|
242
|
+
return_dn.append("items", _dict(
|
|
243
|
+
item_code=item.get("item_code"),
|
|
244
|
+
item_name=item.get("item_name"),
|
|
245
|
+
description=item.get("description"),
|
|
246
|
+
qty=-flt(item.get("qty")),
|
|
247
|
+
uom=item.get("uom"),
|
|
248
|
+
rate=flt(item.get("rate")),
|
|
249
|
+
warehouse=item.get("warehouse"),
|
|
250
|
+
against_sales_order=item.get("against_sales_order"),
|
|
251
|
+
so_detail=item.get("so_detail"),
|
|
252
|
+
))
|
|
253
|
+
|
|
254
|
+
return return_dn
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purchase Receipt.
|
|
3
|
+
|
|
4
|
+
Purchase Receipt records goods received from a supplier against a Purchase Order:
|
|
5
|
+
Purchase Order -> Purchase Receipt -> Purchase Invoice
|
|
6
|
+
|
|
7
|
+
Key behaviors:
|
|
8
|
+
- Creates Stock Ledger Entries (stock IN to warehouse)
|
|
9
|
+
- Creates GL entries (Dr: Stock In Hand, Cr: Stock Received But Not Billed)
|
|
10
|
+
- Updates received_qty on the linked Purchase Order
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from lambda_erp.model import Document
|
|
14
|
+
from lambda_erp.utils import _dict, flt, 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
|
+
from lambda_erp.stock.stock_ledger import make_sl_entries
|
|
20
|
+
from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
|
|
21
|
+
|
|
22
|
+
class PurchaseReceipt(Document):
|
|
23
|
+
DOCTYPE = "Purchase Receipt"
|
|
24
|
+
CHILD_TABLES = {
|
|
25
|
+
"items": ("Purchase Receipt Item", None),
|
|
26
|
+
"taxes": ("Sales Taxes and Charges", None),
|
|
27
|
+
}
|
|
28
|
+
PREFIX = "PREC"
|
|
29
|
+
|
|
30
|
+
LINK_FIELDS = {
|
|
31
|
+
"supplier": "Supplier",
|
|
32
|
+
"company": "Company",
|
|
33
|
+
}
|
|
34
|
+
CHILD_LINK_FIELDS = {
|
|
35
|
+
"items": {
|
|
36
|
+
"item_code": "Item",
|
|
37
|
+
"warehouse": "Warehouse",
|
|
38
|
+
},
|
|
39
|
+
"taxes": {
|
|
40
|
+
"account_head": "Account",
|
|
41
|
+
"cost_center": "Cost Center",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def validate(self):
|
|
46
|
+
if not self.supplier:
|
|
47
|
+
raise ValidationError("Supplier is required")
|
|
48
|
+
if not self.get("items"):
|
|
49
|
+
raise ValidationError("At least one item is required")
|
|
50
|
+
if not self.posting_date:
|
|
51
|
+
self.posting_date = nowdate()
|
|
52
|
+
|
|
53
|
+
self._set_supplier_name()
|
|
54
|
+
self._set_item_defaults()
|
|
55
|
+
|
|
56
|
+
if self.is_return:
|
|
57
|
+
self._validate_return()
|
|
58
|
+
|
|
59
|
+
set_default_currency(self, "Supplier", "supplier")
|
|
60
|
+
calculate_taxes_and_totals(self)
|
|
61
|
+
self._set_status()
|
|
62
|
+
|
|
63
|
+
def _set_supplier_name(self):
|
|
64
|
+
if not self.supplier_name and self.supplier:
|
|
65
|
+
db = get_db()
|
|
66
|
+
self.supplier_name = db.get_value("Supplier", self.supplier, "supplier_name")
|
|
67
|
+
|
|
68
|
+
def _set_item_defaults(self):
|
|
69
|
+
db = get_db()
|
|
70
|
+
for item in self.get("items"):
|
|
71
|
+
if item.get("item_code") and not item.get("item_name"):
|
|
72
|
+
item_data = db.get_value(
|
|
73
|
+
"Item", item["item_code"],
|
|
74
|
+
["item_name", "description", "stock_uom", "standard_rate"]
|
|
75
|
+
)
|
|
76
|
+
if item_data:
|
|
77
|
+
item["item_name"] = item_data.item_name
|
|
78
|
+
item["description"] = item.get("description") or item_data.description
|
|
79
|
+
item["uom"] = item.get("uom") or item_data.stock_uom
|
|
80
|
+
if not item.get("rate"):
|
|
81
|
+
item["rate"] = flt(item_data.standard_rate)
|
|
82
|
+
|
|
83
|
+
def _validate_return(self):
|
|
84
|
+
"""Validate return-specific rules."""
|
|
85
|
+
if not self.return_against:
|
|
86
|
+
raise ValidationError("Return Against is required for a return Purchase Receipt")
|
|
87
|
+
|
|
88
|
+
db = get_db()
|
|
89
|
+
original = db.get_value(self.DOCTYPE, self.return_against, ["name", "docstatus"])
|
|
90
|
+
if not original:
|
|
91
|
+
raise ValidationError(f"Original Purchase Receipt {self.return_against} not found")
|
|
92
|
+
if original.docstatus != 1:
|
|
93
|
+
raise ValidationError(f"Original Purchase Receipt {self.return_against} must be submitted")
|
|
94
|
+
|
|
95
|
+
original_doc = PurchaseReceipt.load(self.return_against)
|
|
96
|
+
original_items = {item["item_code"]: flt(item["qty"]) for item in original_doc.get("items")}
|
|
97
|
+
for item in self.get("items"):
|
|
98
|
+
orig_qty = original_items.get(item.get("item_code"), 0)
|
|
99
|
+
return_qty = abs(flt(item.get("qty")))
|
|
100
|
+
if return_qty > orig_qty:
|
|
101
|
+
raise ValidationError(
|
|
102
|
+
f"Return qty ({return_qty}) for {item.get('item_code')} exceeds "
|
|
103
|
+
f"original qty ({orig_qty})"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _set_status(self):
|
|
107
|
+
if self.docstatus == 0:
|
|
108
|
+
self._data["status"] = "Draft"
|
|
109
|
+
elif self.docstatus == 2:
|
|
110
|
+
self._data["status"] = "Cancelled"
|
|
111
|
+
elif self.docstatus == 1:
|
|
112
|
+
if flt(self.per_billed) >= 100:
|
|
113
|
+
self._data["status"] = "Completed"
|
|
114
|
+
else:
|
|
115
|
+
self._data["status"] = "To Bill"
|
|
116
|
+
|
|
117
|
+
def on_submit(self):
|
|
118
|
+
sl_entries = self._get_sl_entries()
|
|
119
|
+
make_sl_entries(sl_entries)
|
|
120
|
+
|
|
121
|
+
gl_entries = self._get_gl_entries()
|
|
122
|
+
if gl_entries:
|
|
123
|
+
# Dr Stock-In-Hand / Cr SRBNB are built in document currency; convert
|
|
124
|
+
# to base. _get_sl_entries already values the stock in base, so the
|
|
125
|
+
# SIH GL matches the stock-ledger value.
|
|
126
|
+
to_base_currency(gl_entries, self.get("conversion_rate"))
|
|
127
|
+
make_gl_entries(gl_entries)
|
|
128
|
+
|
|
129
|
+
self._update_purchase_order_received()
|
|
130
|
+
|
|
131
|
+
def on_cancel(self):
|
|
132
|
+
# Must be the first thing — if we've already touched the ledgers,
|
|
133
|
+
# raising here leaves them half-reversed until the outer transaction
|
|
134
|
+
# rolls back. Cheap check first, expensive work after.
|
|
135
|
+
self._check_no_linked_purchase_invoice()
|
|
136
|
+
|
|
137
|
+
sl_entries = self._get_sl_entries()
|
|
138
|
+
for sle in sl_entries:
|
|
139
|
+
sle["actual_qty"] = -flt(sle["actual_qty"])
|
|
140
|
+
incoming = sle.get("incoming_rate", 0)
|
|
141
|
+
outgoing = sle.get("outgoing_rate", 0)
|
|
142
|
+
sle["incoming_rate"] = outgoing
|
|
143
|
+
sle["outgoing_rate"] = incoming
|
|
144
|
+
make_sl_entries(sl_entries, allow_negative_stock=True)
|
|
145
|
+
|
|
146
|
+
make_reverse_gl_entries(
|
|
147
|
+
voucher_type=self.DOCTYPE,
|
|
148
|
+
voucher_no=self.name,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self._update_purchase_order_received(cancel=True)
|
|
152
|
+
|
|
153
|
+
def _check_no_linked_purchase_invoice(self):
|
|
154
|
+
"""Block cancel if a submitted Purchase Invoice for the same PO has
|
|
155
|
+
already cleared this receipt's SRBNB entry. Otherwise the reversal
|
|
156
|
+
here (Cr SIH / Dr SRBNB) runs against a SRBNB account the PI already
|
|
157
|
+
zeroed, producing an orphan SRBNB debit with no real payable to match.
|
|
158
|
+
User's recourse: cancel the PI first, then retry the PR cancel.
|
|
159
|
+
"""
|
|
160
|
+
db = get_db()
|
|
161
|
+
pos = {
|
|
162
|
+
item.get("against_purchase_order")
|
|
163
|
+
for item in self.get("items")
|
|
164
|
+
if item.get("against_purchase_order")
|
|
165
|
+
}
|
|
166
|
+
if not pos:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
placeholders = ",".join("?" * len(pos))
|
|
170
|
+
rows = db.sql(
|
|
171
|
+
f'SELECT DISTINCT pi_parent.name AS pi_name '
|
|
172
|
+
f'FROM "Purchase Invoice Item" pii '
|
|
173
|
+
f'JOIN "Purchase Invoice" pi_parent ON pi_parent.name = pii.parent '
|
|
174
|
+
f'WHERE pi_parent.docstatus = 1 '
|
|
175
|
+
f' AND pii.purchase_order IN ({placeholders})',
|
|
176
|
+
list(pos),
|
|
177
|
+
)
|
|
178
|
+
if rows:
|
|
179
|
+
raise ValidationError(
|
|
180
|
+
f"Cannot cancel {self.name}: Purchase Invoice {rows[0]['pi_name']} "
|
|
181
|
+
f"is already submitted against the same Purchase Order. Cancel "
|
|
182
|
+
f"the Purchase Invoice first so SRBNB returns to its pre-bill state."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _get_sl_entries(self):
|
|
186
|
+
# Inventory is valued in the company's base currency; the line rate is
|
|
187
|
+
# in document currency, so scale it by conversion_rate.
|
|
188
|
+
conversion_rate = flt(self.get("conversion_rate")) or 1.0
|
|
189
|
+
sl_entries = []
|
|
190
|
+
for item in self.get("items"):
|
|
191
|
+
warehouse = item.get("warehouse")
|
|
192
|
+
if not warehouse:
|
|
193
|
+
continue
|
|
194
|
+
actual_qty = flt(item["qty"]) # Normal: positive (in). Return: negative (out).
|
|
195
|
+
rate = flt(item.get("rate", 0)) * conversion_rate
|
|
196
|
+
sl_entries.append(_dict(
|
|
197
|
+
item_code=item["item_code"],
|
|
198
|
+
warehouse=warehouse,
|
|
199
|
+
actual_qty=actual_qty,
|
|
200
|
+
incoming_rate=rate if actual_qty > 0 else 0,
|
|
201
|
+
outgoing_rate=rate if actual_qty < 0 else 0,
|
|
202
|
+
voucher_type=self.DOCTYPE,
|
|
203
|
+
voucher_no=self.name,
|
|
204
|
+
voucher_detail_no=item.get("name"),
|
|
205
|
+
posting_date=self.posting_date,
|
|
206
|
+
company=self.company,
|
|
207
|
+
))
|
|
208
|
+
return sl_entries
|
|
209
|
+
|
|
210
|
+
def _get_gl_entries(self):
|
|
211
|
+
db = get_db()
|
|
212
|
+
gl_entries = []
|
|
213
|
+
|
|
214
|
+
stock_account = db.get_value("Company", self.company, "stock_in_hand_account")
|
|
215
|
+
srbnb_account = db.get_value("Company", self.company, "stock_received_but_not_billed")
|
|
216
|
+
|
|
217
|
+
if not stock_account or not srbnb_account:
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
total = sum(flt(item.get("amount", 0)) for item in self.get("items"))
|
|
221
|
+
if not total:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
# Dr: Stock In Hand
|
|
225
|
+
gl_entries.append(_dict(
|
|
226
|
+
account=stock_account,
|
|
227
|
+
debit=flt(total, 2),
|
|
228
|
+
debit_in_account_currency=flt(total, 2),
|
|
229
|
+
credit=0,
|
|
230
|
+
credit_in_account_currency=0,
|
|
231
|
+
voucher_type=self.DOCTYPE,
|
|
232
|
+
voucher_no=self.name,
|
|
233
|
+
posting_date=self.posting_date,
|
|
234
|
+
company=self.company,
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
# Cr: Stock Received But Not Billed
|
|
238
|
+
gl_entries.append(_dict(
|
|
239
|
+
account=srbnb_account,
|
|
240
|
+
debit=0,
|
|
241
|
+
debit_in_account_currency=0,
|
|
242
|
+
credit=flt(total, 2),
|
|
243
|
+
credit_in_account_currency=flt(total, 2),
|
|
244
|
+
voucher_type=self.DOCTYPE,
|
|
245
|
+
voucher_no=self.name,
|
|
246
|
+
posting_date=self.posting_date,
|
|
247
|
+
company=self.company,
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
return gl_entries
|
|
251
|
+
|
|
252
|
+
def _update_purchase_order_received(self, cancel=False):
|
|
253
|
+
db = get_db()
|
|
254
|
+
updated_pos = set()
|
|
255
|
+
po_details = set()
|
|
256
|
+
for item in self.get("items"):
|
|
257
|
+
if item.get("against_purchase_order"):
|
|
258
|
+
updated_pos.add(item["against_purchase_order"])
|
|
259
|
+
if item.get("po_detail"):
|
|
260
|
+
po_details.add(item["po_detail"])
|
|
261
|
+
|
|
262
|
+
for po_detail in po_details:
|
|
263
|
+
result = db.sql(
|
|
264
|
+
"""SELECT COALESCE(SUM(qty), 0) as total_received
|
|
265
|
+
FROM "Purchase Receipt Item"
|
|
266
|
+
WHERE po_detail = ?
|
|
267
|
+
AND parent IN (
|
|
268
|
+
SELECT name FROM "Purchase Receipt" WHERE docstatus = 1
|
|
269
|
+
)""",
|
|
270
|
+
[po_detail],
|
|
271
|
+
)
|
|
272
|
+
received = flt(result[0]["total_received"]) if result else 0
|
|
273
|
+
db.set_value("Purchase Order Item", po_detail, "received_qty", received)
|
|
274
|
+
|
|
275
|
+
for po_name in updated_pos:
|
|
276
|
+
from lambda_erp.buying.purchase_order import PurchaseOrder
|
|
277
|
+
po = PurchaseOrder.load(po_name)
|
|
278
|
+
po.update_receipt_status()
|
|
279
|
+
|
|
280
|
+
def make_purchase_receipt(purchase_order_name):
|
|
281
|
+
"""Convert a Purchase Order into a Purchase Receipt."""
|
|
282
|
+
from lambda_erp.buying.purchase_order import PurchaseOrder
|
|
283
|
+
|
|
284
|
+
po = PurchaseOrder.load(purchase_order_name)
|
|
285
|
+
if po.docstatus != 1:
|
|
286
|
+
raise ValidationError("Purchase Order must be submitted before creating Purchase Receipt")
|
|
287
|
+
|
|
288
|
+
pr = PurchaseReceipt(
|
|
289
|
+
supplier=po.supplier,
|
|
290
|
+
supplier_name=po.supplier_name,
|
|
291
|
+
company=po.company,
|
|
292
|
+
currency=po.currency,
|
|
293
|
+
conversion_rate=po.conversion_rate,
|
|
294
|
+
posting_date=nowdate(),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
for item in po.get("items"):
|
|
298
|
+
unreceived = flt(item.get("qty")) - flt(item.get("received_qty"))
|
|
299
|
+
if unreceived <= 0:
|
|
300
|
+
continue
|
|
301
|
+
pr.append("items", _dict(
|
|
302
|
+
item_code=item.get("item_code"),
|
|
303
|
+
item_name=item.get("item_name"),
|
|
304
|
+
description=item.get("description"),
|
|
305
|
+
qty=unreceived,
|
|
306
|
+
uom=item.get("uom"),
|
|
307
|
+
rate=item.get("rate"),
|
|
308
|
+
warehouse=item.get("warehouse"),
|
|
309
|
+
against_purchase_order=po.name,
|
|
310
|
+
po_detail=item.get("name"),
|
|
311
|
+
))
|
|
312
|
+
|
|
313
|
+
for tax in po.get("taxes") or []:
|
|
314
|
+
pr.append("taxes", _dict(
|
|
315
|
+
charge_type=tax.get("charge_type"),
|
|
316
|
+
account_head=tax.get("account_head"),
|
|
317
|
+
description=tax.get("description"),
|
|
318
|
+
rate=tax.get("rate"),
|
|
319
|
+
tax_amount=0,
|
|
320
|
+
))
|
|
321
|
+
|
|
322
|
+
return pr
|
|
323
|
+
|
|
324
|
+
def make_purchase_receipt_return(prec_name):
|
|
325
|
+
"""Create a return Purchase Receipt (stock back out) from an existing Purchase Receipt."""
|
|
326
|
+
original = PurchaseReceipt.load(prec_name)
|
|
327
|
+
|
|
328
|
+
if original.docstatus != 1:
|
|
329
|
+
raise ValidationError("Purchase Receipt must be submitted before creating a return")
|
|
330
|
+
if original.is_return:
|
|
331
|
+
raise ValidationError("Cannot create a return against a return")
|
|
332
|
+
|
|
333
|
+
return_pr = PurchaseReceipt(
|
|
334
|
+
supplier=original.supplier,
|
|
335
|
+
company=original.company,
|
|
336
|
+
currency=original.get("currency") or "USD",
|
|
337
|
+
conversion_rate=original.get("conversion_rate") or 1.0,
|
|
338
|
+
posting_date=nowdate(),
|
|
339
|
+
is_return=1,
|
|
340
|
+
return_against=original.name,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
for item in original.get("items"):
|
|
344
|
+
return_pr.append("items", _dict(
|
|
345
|
+
item_code=item.get("item_code"),
|
|
346
|
+
item_name=item.get("item_name"),
|
|
347
|
+
description=item.get("description"),
|
|
348
|
+
qty=-flt(item.get("qty")),
|
|
349
|
+
uom=item.get("uom"),
|
|
350
|
+
rate=flt(item.get("rate")),
|
|
351
|
+
warehouse=item.get("warehouse"),
|
|
352
|
+
against_purchase_order=item.get("against_purchase_order"),
|
|
353
|
+
po_detail=item.get("po_detail"),
|
|
354
|
+
))
|
|
355
|
+
|
|
356
|
+
return return_pr
|