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,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stock Entry - Material movements.
|
|
3
|
+
|
|
4
|
+
Stock Entry handles all material movements:
|
|
5
|
+
- Material Receipt: goods coming INTO a warehouse (no source)
|
|
6
|
+
- Material Issue: goods going OUT of a warehouse (no target)
|
|
7
|
+
- Material Transfer: goods moving between warehouses
|
|
8
|
+
|
|
9
|
+
Each type creates Stock Ledger Entries (SLEs) and optionally
|
|
10
|
+
GL entries for perpetual inventory.
|
|
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.stock.stock_ledger import make_sl_entries
|
|
17
|
+
from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries
|
|
18
|
+
from lambda_erp.exceptions import ValidationError
|
|
19
|
+
|
|
20
|
+
class StockEntry(Document):
|
|
21
|
+
DOCTYPE = "Stock Entry"
|
|
22
|
+
CHILD_TABLES = {
|
|
23
|
+
"items": ("Stock Entry Detail", None),
|
|
24
|
+
}
|
|
25
|
+
PREFIX = "STE"
|
|
26
|
+
|
|
27
|
+
LINK_FIELDS = {
|
|
28
|
+
"company": "Company",
|
|
29
|
+
"from_warehouse": "Warehouse",
|
|
30
|
+
"to_warehouse": "Warehouse",
|
|
31
|
+
}
|
|
32
|
+
CHILD_LINK_FIELDS = {
|
|
33
|
+
"items": {
|
|
34
|
+
"item_code": "Item",
|
|
35
|
+
"s_warehouse": "Warehouse",
|
|
36
|
+
"t_warehouse": "Warehouse",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def validate(self):
|
|
41
|
+
if not self.stock_entry_type:
|
|
42
|
+
raise ValidationError(
|
|
43
|
+
"Stock Entry Type is required (Material Receipt, Material Issue, Material Transfer)"
|
|
44
|
+
)
|
|
45
|
+
if not self.get("items"):
|
|
46
|
+
raise ValidationError("At least one item is required")
|
|
47
|
+
if not self.posting_date:
|
|
48
|
+
self.posting_date = nowdate()
|
|
49
|
+
|
|
50
|
+
self._validate_warehouses()
|
|
51
|
+
self._set_item_defaults()
|
|
52
|
+
self._calculate_totals()
|
|
53
|
+
|
|
54
|
+
def _validate_warehouses(self):
|
|
55
|
+
"""Validate source/target warehouses based on entry type."""
|
|
56
|
+
for item in self.get("items"):
|
|
57
|
+
if self.stock_entry_type in ("Material Receipt", "Opening Stock"):
|
|
58
|
+
if not item.get("t_warehouse"):
|
|
59
|
+
item["t_warehouse"] = self.to_warehouse
|
|
60
|
+
if not item.get("t_warehouse"):
|
|
61
|
+
raise ValidationError(
|
|
62
|
+
f"Target Warehouse is required for {self.stock_entry_type} (Item: {item.get('item_code')})"
|
|
63
|
+
)
|
|
64
|
+
elif self.stock_entry_type == "Material Issue":
|
|
65
|
+
if not item.get("s_warehouse"):
|
|
66
|
+
item["s_warehouse"] = self.from_warehouse
|
|
67
|
+
if not item.get("s_warehouse"):
|
|
68
|
+
raise ValidationError(
|
|
69
|
+
f"Source Warehouse is required for Material Issue (Item: {item.get('item_code')})"
|
|
70
|
+
)
|
|
71
|
+
elif self.stock_entry_type == "Material Transfer":
|
|
72
|
+
if not item.get("s_warehouse"):
|
|
73
|
+
item["s_warehouse"] = self.from_warehouse
|
|
74
|
+
if not item.get("t_warehouse"):
|
|
75
|
+
item["t_warehouse"] = self.to_warehouse
|
|
76
|
+
if not item.get("s_warehouse") or not item.get("t_warehouse"):
|
|
77
|
+
raise ValidationError(
|
|
78
|
+
f"Both Source and Target Warehouse required for Transfer (Item: {item.get('item_code')})"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _set_item_defaults(self):
|
|
82
|
+
db = get_db()
|
|
83
|
+
for item in self.get("items"):
|
|
84
|
+
if item.get("item_code") and not item.get("item_name"):
|
|
85
|
+
item_data = db.get_value(
|
|
86
|
+
"Item", item["item_code"], ["item_name", "stock_uom", "standard_rate"]
|
|
87
|
+
)
|
|
88
|
+
if item_data:
|
|
89
|
+
item["item_name"] = item_data.item_name
|
|
90
|
+
item["uom"] = item.get("uom") or item_data.stock_uom
|
|
91
|
+
|
|
92
|
+
def _calculate_totals(self):
|
|
93
|
+
"""Calculate total values for the stock entry."""
|
|
94
|
+
total_incoming = 0
|
|
95
|
+
total_outgoing = 0
|
|
96
|
+
total_amount = 0
|
|
97
|
+
|
|
98
|
+
for item in self.get("items"):
|
|
99
|
+
qty = flt(item.get("qty", 0))
|
|
100
|
+
rate = flt(item.get("basic_rate", 0))
|
|
101
|
+
item["basic_amount"] = flt(qty * rate, 2)
|
|
102
|
+
item["amount"] = item["basic_amount"]
|
|
103
|
+
|
|
104
|
+
if item.get("t_warehouse"):
|
|
105
|
+
total_incoming += item["basic_amount"]
|
|
106
|
+
if item.get("s_warehouse"):
|
|
107
|
+
total_outgoing += item["basic_amount"]
|
|
108
|
+
total_amount += item["basic_amount"]
|
|
109
|
+
|
|
110
|
+
self._data["total_incoming_value"] = flt(total_incoming, 2)
|
|
111
|
+
self._data["total_outgoing_value"] = flt(total_outgoing, 2)
|
|
112
|
+
self._data["value_difference"] = flt(total_incoming - total_outgoing, 2)
|
|
113
|
+
self._data["total_amount"] = flt(total_amount, 2)
|
|
114
|
+
|
|
115
|
+
def on_submit(self):
|
|
116
|
+
"""Create Stock Ledger Entries and GL entries.
|
|
117
|
+
|
|
118
|
+
- update_stock_ledger() -> make_sl_entries()
|
|
119
|
+
- make_gl_entries() (for perpetual inventory)
|
|
120
|
+
"""
|
|
121
|
+
sl_entries = self._get_sl_entries()
|
|
122
|
+
make_sl_entries(sl_entries)
|
|
123
|
+
|
|
124
|
+
# GL entries for perpetual inventory
|
|
125
|
+
gl_entries = self._get_gl_entries()
|
|
126
|
+
if gl_entries:
|
|
127
|
+
make_gl_entries(gl_entries)
|
|
128
|
+
|
|
129
|
+
def on_cancel(self):
|
|
130
|
+
"""Reverse SLEs and GL entries."""
|
|
131
|
+
# Reverse SLEs by creating negative entries
|
|
132
|
+
sl_entries = self._get_sl_entries()
|
|
133
|
+
for sle in sl_entries:
|
|
134
|
+
sle["actual_qty"] = -flt(sle["actual_qty"])
|
|
135
|
+
# Swap incoming/outgoing rates
|
|
136
|
+
incoming = sle.get("incoming_rate", 0)
|
|
137
|
+
outgoing = sle.get("outgoing_rate", 0)
|
|
138
|
+
sle["incoming_rate"] = outgoing
|
|
139
|
+
sle["outgoing_rate"] = incoming
|
|
140
|
+
make_sl_entries(sl_entries, allow_negative_stock=True)
|
|
141
|
+
|
|
142
|
+
# Reverse GL entries
|
|
143
|
+
make_reverse_gl_entries(
|
|
144
|
+
voucher_type=self.DOCTYPE,
|
|
145
|
+
voucher_no=self.name,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _get_sl_entries(self):
|
|
149
|
+
"""Build Stock Ledger Entry list.
|
|
150
|
+
|
|
151
|
+
For Material Transfer, each item creates TWO SLEs:
|
|
152
|
+
1. Negative from source warehouse
|
|
153
|
+
2. Positive to target warehouse
|
|
154
|
+
"""
|
|
155
|
+
sl_entries = []
|
|
156
|
+
|
|
157
|
+
for item in self.get("items"):
|
|
158
|
+
# Outgoing (from source warehouse)
|
|
159
|
+
if item.get("s_warehouse"):
|
|
160
|
+
sl_entries.append(
|
|
161
|
+
_dict(
|
|
162
|
+
item_code=item["item_code"],
|
|
163
|
+
warehouse=item["s_warehouse"],
|
|
164
|
+
actual_qty=-flt(item["qty"]),
|
|
165
|
+
outgoing_rate=flt(item.get("basic_rate") or item.get("valuation_rate", 0)),
|
|
166
|
+
incoming_rate=0,
|
|
167
|
+
voucher_type=self.DOCTYPE,
|
|
168
|
+
voucher_no=self.name,
|
|
169
|
+
voucher_detail_no=item.get("name"),
|
|
170
|
+
posting_date=self.posting_date,
|
|
171
|
+
posting_time=self.posting_time or "00:00:00",
|
|
172
|
+
company=self.company,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Incoming (to target warehouse)
|
|
177
|
+
if item.get("t_warehouse"):
|
|
178
|
+
sl_entries.append(
|
|
179
|
+
_dict(
|
|
180
|
+
item_code=item["item_code"],
|
|
181
|
+
warehouse=item["t_warehouse"],
|
|
182
|
+
actual_qty=flt(item["qty"]),
|
|
183
|
+
incoming_rate=flt(item.get("basic_rate") or item.get("valuation_rate", 0)),
|
|
184
|
+
outgoing_rate=0,
|
|
185
|
+
voucher_type=self.DOCTYPE,
|
|
186
|
+
voucher_no=self.name,
|
|
187
|
+
voucher_detail_no=item.get("name"),
|
|
188
|
+
posting_date=self.posting_date,
|
|
189
|
+
posting_time=self.posting_time or "00:00:00",
|
|
190
|
+
company=self.company,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return sl_entries
|
|
195
|
+
|
|
196
|
+
def _get_gl_entries(self):
|
|
197
|
+
"""Build GL entries for perpetual inventory.
|
|
198
|
+
|
|
199
|
+
In perpetual inventory, stock movements also create accounting entries:
|
|
200
|
+
- Opening Stock: Debit Stock In Hand, Credit Opening Balance Equity
|
|
201
|
+
(one-time seed of inventory at company setup)
|
|
202
|
+
- Material Receipt: Debit Stock In Hand, Credit Stock Adjustment
|
|
203
|
+
(manual adjustments / found stock, not supplier deliveries)
|
|
204
|
+
- Material Issue: Debit Stock Adjustment, Credit Stock In Hand
|
|
205
|
+
(write-offs / internal consumption)
|
|
206
|
+
- Material Transfer: No GL impact (same Stock In Hand account)
|
|
207
|
+
"""
|
|
208
|
+
db = get_db()
|
|
209
|
+
if not self.company:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
gl_entries = []
|
|
213
|
+
stock_account = None
|
|
214
|
+
expense_account = None
|
|
215
|
+
|
|
216
|
+
# Get stock and expense accounts from warehouse or company defaults
|
|
217
|
+
for item in self.get("items"):
|
|
218
|
+
if item.get("t_warehouse"):
|
|
219
|
+
stock_account = (
|
|
220
|
+
db.get_value("Warehouse", item["t_warehouse"], "account")
|
|
221
|
+
or db.get_value("Account",
|
|
222
|
+
{"company": self.company, "account_type": "Stock", "is_group": 0},
|
|
223
|
+
"name")
|
|
224
|
+
)
|
|
225
|
+
if item.get("s_warehouse"):
|
|
226
|
+
stock_account = stock_account or (
|
|
227
|
+
db.get_value("Warehouse", item["s_warehouse"], "account")
|
|
228
|
+
or db.get_value("Account",
|
|
229
|
+
{"company": self.company, "account_type": "Stock", "is_group": 0},
|
|
230
|
+
"name")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if not stock_account:
|
|
234
|
+
return [] # No perpetual inventory
|
|
235
|
+
|
|
236
|
+
cost_center = db.get_value("Company", self.company, "default_cost_center")
|
|
237
|
+
|
|
238
|
+
if self.stock_entry_type == "Opening Stock":
|
|
239
|
+
# One-time seed of inventory at company setup. Contra to equity
|
|
240
|
+
# (Opening Balance Equity) so the P&L is not distorted by stock
|
|
241
|
+
# that the business had on day one but didn't "earn".
|
|
242
|
+
contra_account = db.get_value(
|
|
243
|
+
"Company", self.company, "default_opening_balance_equity"
|
|
244
|
+
)
|
|
245
|
+
if stock_account and contra_account:
|
|
246
|
+
gl_entries = [
|
|
247
|
+
_dict(
|
|
248
|
+
account=stock_account,
|
|
249
|
+
debit=flt(self.total_incoming_value, 2),
|
|
250
|
+
debit_in_account_currency=flt(self.total_incoming_value, 2),
|
|
251
|
+
credit=0, credit_in_account_currency=0,
|
|
252
|
+
cost_center=cost_center,
|
|
253
|
+
voucher_type=self.DOCTYPE, voucher_no=self.name,
|
|
254
|
+
posting_date=self.posting_date, company=self.company,
|
|
255
|
+
remarks=f"Opening stock via {self.name}",
|
|
256
|
+
),
|
|
257
|
+
_dict(
|
|
258
|
+
account=contra_account,
|
|
259
|
+
credit=flt(self.total_incoming_value, 2),
|
|
260
|
+
credit_in_account_currency=flt(self.total_incoming_value, 2),
|
|
261
|
+
debit=0, debit_in_account_currency=0,
|
|
262
|
+
cost_center=cost_center,
|
|
263
|
+
voucher_type=self.DOCTYPE, voucher_no=self.name,
|
|
264
|
+
posting_date=self.posting_date, company=self.company,
|
|
265
|
+
remarks=f"Opening stock via {self.name}",
|
|
266
|
+
),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
elif self.stock_entry_type == "Material Receipt":
|
|
270
|
+
# Manual inventory receipts (adjustments, found stock). Contra to
|
|
271
|
+
# Stock Adjustment (expense). For opening balances use the
|
|
272
|
+
# dedicated "Opening Stock" type above instead.
|
|
273
|
+
contra_account = db.get_value("Company", self.company, "stock_adjustment_account")
|
|
274
|
+
if stock_account and contra_account:
|
|
275
|
+
gl_entries = [
|
|
276
|
+
_dict(
|
|
277
|
+
account=stock_account,
|
|
278
|
+
debit=flt(self.total_incoming_value, 2),
|
|
279
|
+
debit_in_account_currency=flt(self.total_incoming_value, 2),
|
|
280
|
+
credit=0, credit_in_account_currency=0,
|
|
281
|
+
cost_center=cost_center,
|
|
282
|
+
voucher_type=self.DOCTYPE, voucher_no=self.name,
|
|
283
|
+
posting_date=self.posting_date, company=self.company,
|
|
284
|
+
remarks=f"Material Receipt via {self.name}",
|
|
285
|
+
),
|
|
286
|
+
_dict(
|
|
287
|
+
account=contra_account,
|
|
288
|
+
credit=flt(self.total_incoming_value, 2),
|
|
289
|
+
credit_in_account_currency=flt(self.total_incoming_value, 2),
|
|
290
|
+
debit=0, debit_in_account_currency=0,
|
|
291
|
+
cost_center=cost_center,
|
|
292
|
+
voucher_type=self.DOCTYPE, voucher_no=self.name,
|
|
293
|
+
posting_date=self.posting_date, company=self.company,
|
|
294
|
+
remarks=f"Material Receipt via {self.name}",
|
|
295
|
+
),
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
elif self.stock_entry_type == "Material Issue":
|
|
299
|
+
# Manual issues are write-offs or internal consumption, not sales.
|
|
300
|
+
# Route them to Stock Adjustment rather than COGS/default expense —
|
|
301
|
+
# COGS should only be credited/debited by documents that actually
|
|
302
|
+
# correspond to a sale (Sales Invoice, Delivery Note, POS).
|
|
303
|
+
expense_account = db.get_value("Company", self.company, "stock_adjustment_account")
|
|
304
|
+
if stock_account and expense_account:
|
|
305
|
+
gl_entries = [
|
|
306
|
+
_dict(
|
|
307
|
+
account=expense_account,
|
|
308
|
+
debit=flt(self.total_outgoing_value, 2),
|
|
309
|
+
debit_in_account_currency=flt(self.total_outgoing_value, 2),
|
|
310
|
+
credit=0, credit_in_account_currency=0,
|
|
311
|
+
cost_center=cost_center,
|
|
312
|
+
voucher_type=self.DOCTYPE, voucher_no=self.name,
|
|
313
|
+
posting_date=self.posting_date, company=self.company,
|
|
314
|
+
remarks=f"Material Issue via {self.name}",
|
|
315
|
+
),
|
|
316
|
+
_dict(
|
|
317
|
+
account=stock_account,
|
|
318
|
+
credit=flt(self.total_outgoing_value, 2),
|
|
319
|
+
credit_in_account_currency=flt(self.total_outgoing_value, 2),
|
|
320
|
+
debit=0, debit_in_account_currency=0,
|
|
321
|
+
cost_center=cost_center,
|
|
322
|
+
voucher_type=self.DOCTYPE, voucher_no=self.name,
|
|
323
|
+
posting_date=self.posting_date, company=self.company,
|
|
324
|
+
remarks=f"Material Issue via {self.name}",
|
|
325
|
+
),
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
# Material Transfer has no GL impact (stock stays in same Stock In Hand account)
|
|
329
|
+
|
|
330
|
+
return gl_entries
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stock Ledger Entry processing.
|
|
3
|
+
|
|
4
|
+
The heart of inventory management, equivalent to what general_ledger.py is
|
|
5
|
+
for accounting.
|
|
6
|
+
|
|
7
|
+
Every stock movement creates Stock Ledger Entries (SLEs) that track:
|
|
8
|
+
- actual_qty: quantity change (+/-)
|
|
9
|
+
- qty_after_transaction: running balance
|
|
10
|
+
- incoming_rate / outgoing_rate: unit cost
|
|
11
|
+
- valuation_rate: weighted average cost per unit
|
|
12
|
+
- stock_value: total value of stock after this transaction
|
|
13
|
+
|
|
14
|
+
Valuation methods supported:
|
|
15
|
+
- FIFO (First In First Out)
|
|
16
|
+
- Moving Average (weighted average)
|
|
17
|
+
|
|
18
|
+
The flow:
|
|
19
|
+
Document.on_submit() -> StockController.make_sl_entries()
|
|
20
|
+
-> stock_ledger.make_sl_entries(sl_entries)
|
|
21
|
+
-> create SLE records
|
|
22
|
+
-> update_entries_after() (recalculate valuation)
|
|
23
|
+
-> update_bin_qty() (update Bin summary)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from lambda_erp.utils import _dict, flt, new_name, now, nowdate
|
|
27
|
+
from lambda_erp.database import get_db
|
|
28
|
+
from lambda_erp.exceptions import NegativeStockError
|
|
29
|
+
|
|
30
|
+
def make_sl_entries(sl_entries, allow_negative_stock=False):
|
|
31
|
+
"""Create Stock Ledger Entries from a list of SLE dicts.
|
|
32
|
+
|
|
33
|
+
Called on every stock transaction.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
sl_entries: list of _dict with item_code, warehouse, actual_qty,
|
|
37
|
+
incoming_rate, voucher_type, voucher_no, etc.
|
|
38
|
+
allow_negative_stock: if False, raises NegativeStockError
|
|
39
|
+
"""
|
|
40
|
+
db = get_db()
|
|
41
|
+
|
|
42
|
+
if not sl_entries:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
for sle in sl_entries:
|
|
46
|
+
if not flt(sle.get("actual_qty")):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Create the SLE record
|
|
50
|
+
sle_doc = _dict(
|
|
51
|
+
name=new_name("SLE"),
|
|
52
|
+
posting_date=sle.get("posting_date") or nowdate(),
|
|
53
|
+
posting_time=sle.get("posting_time", "00:00:00"),
|
|
54
|
+
item_code=sle["item_code"],
|
|
55
|
+
warehouse=sle["warehouse"],
|
|
56
|
+
actual_qty=flt(sle["actual_qty"]),
|
|
57
|
+
incoming_rate=flt(sle.get("incoming_rate", 0)),
|
|
58
|
+
outgoing_rate=flt(sle.get("outgoing_rate", 0)),
|
|
59
|
+
voucher_type=sle.get("voucher_type"),
|
|
60
|
+
voucher_no=sle.get("voucher_no"),
|
|
61
|
+
voucher_detail_no=sle.get("voucher_detail_no"),
|
|
62
|
+
batch_no=sle.get("batch_no"),
|
|
63
|
+
serial_no=sle.get("serial_no"),
|
|
64
|
+
company=sle.get("company"),
|
|
65
|
+
is_cancelled=0,
|
|
66
|
+
creation=now(),
|
|
67
|
+
modified=now(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Calculate running balances
|
|
71
|
+
update_stock_values(sle_doc, allow_negative_stock)
|
|
72
|
+
|
|
73
|
+
# Persist
|
|
74
|
+
db.insert("Stock Ledger Entry", sle_doc)
|
|
75
|
+
|
|
76
|
+
# Update Bin (summary table)
|
|
77
|
+
update_bin(sle_doc)
|
|
78
|
+
|
|
79
|
+
db.commit()
|
|
80
|
+
|
|
81
|
+
def update_stock_values(sle, allow_negative_stock=False):
|
|
82
|
+
"""Calculate qty_after_transaction, valuation_rate, stock_value.
|
|
83
|
+
|
|
84
|
+
This is a simplified port of the reference implementation's update_entries_after() which
|
|
85
|
+
handles the full FIFO queue or moving average calculation.
|
|
86
|
+
|
|
87
|
+
We implement Moving Average here for simplicity, which is the most
|
|
88
|
+
common valuation method.
|
|
89
|
+
"""
|
|
90
|
+
db = get_db()
|
|
91
|
+
|
|
92
|
+
# Get current stock state from Bin
|
|
93
|
+
bin_data = db.get_value(
|
|
94
|
+
"Bin",
|
|
95
|
+
{"item_code": sle["item_code"], "warehouse": sle["warehouse"]},
|
|
96
|
+
["actual_qty", "valuation_rate", "stock_value"],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
prev_qty = flt(bin_data.actual_qty) if bin_data else 0
|
|
100
|
+
prev_val_rate = flt(bin_data.valuation_rate) if bin_data else 0
|
|
101
|
+
prev_stock_value = flt(bin_data.stock_value) if bin_data else 0
|
|
102
|
+
|
|
103
|
+
new_qty = prev_qty + flt(sle["actual_qty"])
|
|
104
|
+
|
|
105
|
+
# Check for negative stock
|
|
106
|
+
if new_qty < 0 and not allow_negative_stock:
|
|
107
|
+
raise NegativeStockError(
|
|
108
|
+
f"Negative stock not allowed for Item {sle['item_code']} "
|
|
109
|
+
f"in Warehouse {sle['warehouse']}. "
|
|
110
|
+
f"Available: {prev_qty}, Requested: {abs(flt(sle['actual_qty']))}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Calculate valuation using Moving Average
|
|
114
|
+
# (the reference implementation also supports FIFO via a stock queue, but moving average
|
|
115
|
+
# is the simpler and more common method)
|
|
116
|
+
if flt(sle["actual_qty"]) > 0:
|
|
117
|
+
# Incoming: weighted average of existing stock + new stock.
|
|
118
|
+
# If the caller passes 0 (e.g. a customer-return delivery note), use
|
|
119
|
+
# the current moving-average so the return lands at the same cost
|
|
120
|
+
# basis the shipment went out at — symmetric with the outgoing branch.
|
|
121
|
+
incoming_rate = flt(sle.get("incoming_rate")) or prev_val_rate
|
|
122
|
+
incoming_value = flt(sle["actual_qty"]) * incoming_rate
|
|
123
|
+
new_stock_value = prev_stock_value + incoming_value
|
|
124
|
+
|
|
125
|
+
if new_qty > 0:
|
|
126
|
+
new_val_rate = new_stock_value / new_qty
|
|
127
|
+
else:
|
|
128
|
+
new_val_rate = incoming_rate
|
|
129
|
+
|
|
130
|
+
sle["incoming_rate"] = incoming_rate
|
|
131
|
+
sle["stock_value_difference"] = incoming_value
|
|
132
|
+
else:
|
|
133
|
+
# Outgoing: use current valuation rate
|
|
134
|
+
outgoing_rate = flt(sle.get("outgoing_rate")) or prev_val_rate
|
|
135
|
+
outgoing_value = abs(flt(sle["actual_qty"])) * outgoing_rate
|
|
136
|
+
new_stock_value = prev_stock_value - outgoing_value
|
|
137
|
+
new_val_rate = prev_val_rate # doesn't change on outgoing
|
|
138
|
+
|
|
139
|
+
sle["outgoing_rate"] = outgoing_rate
|
|
140
|
+
sle["stock_value_difference"] = -outgoing_value
|
|
141
|
+
|
|
142
|
+
sle["qty_after_transaction"] = flt(new_qty)
|
|
143
|
+
sle["valuation_rate"] = flt(new_val_rate, 2)
|
|
144
|
+
sle["stock_value"] = flt(new_stock_value, 2)
|
|
145
|
+
|
|
146
|
+
def update_bin(sle):
|
|
147
|
+
"""Update the Bin (stock summary) table after an SLE.
|
|
148
|
+
|
|
149
|
+
The Bin table maintains current stock levels per item+warehouse.
|
|
150
|
+
It's the quick-lookup table for "how much do we have in stock?"
|
|
151
|
+
"""
|
|
152
|
+
db = get_db()
|
|
153
|
+
bin_name = f"{sle['item_code']}-{sle['warehouse']}"
|
|
154
|
+
|
|
155
|
+
if db.exists("Bin", bin_name):
|
|
156
|
+
db.set_value("Bin", bin_name, {
|
|
157
|
+
"actual_qty": flt(sle["qty_after_transaction"]),
|
|
158
|
+
"valuation_rate": flt(sle["valuation_rate"]),
|
|
159
|
+
"stock_value": flt(sle["stock_value"]),
|
|
160
|
+
})
|
|
161
|
+
else:
|
|
162
|
+
db.insert("Bin", _dict(
|
|
163
|
+
name=bin_name,
|
|
164
|
+
item_code=sle["item_code"],
|
|
165
|
+
warehouse=sle["warehouse"],
|
|
166
|
+
actual_qty=flt(sle["qty_after_transaction"]),
|
|
167
|
+
valuation_rate=flt(sle["valuation_rate"]),
|
|
168
|
+
stock_value=flt(sle["stock_value"]),
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
def get_stock_balance(item_code, warehouse):
|
|
172
|
+
"""Get current stock balance for an item in a warehouse."""
|
|
173
|
+
db = get_db()
|
|
174
|
+
bin_data = db.get_value(
|
|
175
|
+
"Bin",
|
|
176
|
+
{"item_code": item_code, "warehouse": warehouse},
|
|
177
|
+
["actual_qty", "valuation_rate", "stock_value"],
|
|
178
|
+
)
|
|
179
|
+
if bin_data:
|
|
180
|
+
return _dict(bin_data)
|
|
181
|
+
return _dict(actual_qty=0, valuation_rate=0, stock_value=0)
|
|
182
|
+
|
|
183
|
+
def get_stock_balance_all(item_code=None, warehouse=None):
|
|
184
|
+
"""Get stock balances, optionally filtered by item or warehouse."""
|
|
185
|
+
db = get_db()
|
|
186
|
+
filters = {}
|
|
187
|
+
if item_code:
|
|
188
|
+
filters["item_code"] = item_code
|
|
189
|
+
if warehouse:
|
|
190
|
+
filters["warehouse"] = warehouse
|
|
191
|
+
|
|
192
|
+
return db.get_all(
|
|
193
|
+
"Bin",
|
|
194
|
+
filters=filters,
|
|
195
|
+
fields=["item_code", "warehouse", "actual_qty", "valuation_rate", "stock_value"],
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Voucher-level helpers
|
|
200
|
+
#
|
|
201
|
+
# Four documents all move stock and post matching GL: Delivery Note, Sales
|
|
202
|
+
# Invoice (update_stock), POS Invoice (update_stock), Purchase Invoice
|
|
203
|
+
# (update_stock). Before this section existed, each doc reimplemented the
|
|
204
|
+
# SLE + GL boilerplate, which is how POS drifted (sell-rate SLE, no GL) and
|
|
205
|
+
# needed a second fix round. These helpers are the single source of truth.
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def build_sell_side_sles(doc, items):
|
|
209
|
+
"""SLEs for outgoing-stock docs (DN, direct-ship SI, POS update_stock).
|
|
210
|
+
|
|
211
|
+
Negative actual_qty on a normal ship; positive on a return. Rates are
|
|
212
|
+
passed as 0 so the stock ledger uses the moving-average cost for both
|
|
213
|
+
directions — posting COGS at sell value was the original bug.
|
|
214
|
+
"""
|
|
215
|
+
sl_entries = []
|
|
216
|
+
for item in items:
|
|
217
|
+
warehouse = item.get("warehouse")
|
|
218
|
+
if not warehouse or not item.get("item_code"):
|
|
219
|
+
continue
|
|
220
|
+
actual_qty = -flt(item.get("qty"))
|
|
221
|
+
sl_entries.append(_dict(
|
|
222
|
+
item_code=item["item_code"],
|
|
223
|
+
warehouse=warehouse,
|
|
224
|
+
actual_qty=actual_qty,
|
|
225
|
+
outgoing_rate=0,
|
|
226
|
+
incoming_rate=0,
|
|
227
|
+
voucher_type=doc.DOCTYPE,
|
|
228
|
+
voucher_no=doc.name,
|
|
229
|
+
voucher_detail_no=item.get("name"),
|
|
230
|
+
posting_date=doc.posting_date,
|
|
231
|
+
company=doc.company,
|
|
232
|
+
))
|
|
233
|
+
return sl_entries
|
|
234
|
+
|
|
235
|
+
def build_buy_side_sles(doc, items):
|
|
236
|
+
"""SLEs for incoming-stock docs (direct-receive PI update_stock).
|
|
237
|
+
|
|
238
|
+
Positive actual_qty on a normal receipt uses the supplier's invoice rate
|
|
239
|
+
as the incoming cost — that's what the business actually paid, not a
|
|
240
|
+
moving-average over prior stock. Negative actual_qty (return-to-supplier)
|
|
241
|
+
falls back to moving-average via outgoing_rate=0.
|
|
242
|
+
"""
|
|
243
|
+
# Inventory is valued in the company's base currency; line rates are in
|
|
244
|
+
# document currency, so scale by the doc's conversion_rate (1.0 = no-op).
|
|
245
|
+
conversion_rate = flt(doc.get("conversion_rate")) or 1.0
|
|
246
|
+
sl_entries = []
|
|
247
|
+
for item in items:
|
|
248
|
+
warehouse = item.get("warehouse")
|
|
249
|
+
if not warehouse or not item.get("item_code"):
|
|
250
|
+
continue
|
|
251
|
+
actual_qty = flt(item["qty"])
|
|
252
|
+
rate = flt(item.get("net_rate") or item.get("rate", 0)) * conversion_rate
|
|
253
|
+
sl_entries.append(_dict(
|
|
254
|
+
item_code=item["item_code"],
|
|
255
|
+
warehouse=warehouse,
|
|
256
|
+
actual_qty=actual_qty,
|
|
257
|
+
incoming_rate=rate if actual_qty > 0 else 0,
|
|
258
|
+
outgoing_rate=0,
|
|
259
|
+
voucher_type=doc.DOCTYPE,
|
|
260
|
+
voucher_no=doc.name,
|
|
261
|
+
voucher_detail_no=item.get("name"),
|
|
262
|
+
posting_date=doc.posting_date,
|
|
263
|
+
company=doc.company,
|
|
264
|
+
))
|
|
265
|
+
return sl_entries
|
|
266
|
+
|
|
267
|
+
def build_cost_basis_gl(doc, *, remarks=None):
|
|
268
|
+
"""Dr COGS / Cr Stock In Hand at cost for a sell-side doc.
|
|
269
|
+
|
|
270
|
+
Reads stock_value_difference from the SLEs already posted for this
|
|
271
|
+
voucher, so call this AFTER make_sl_entries. Sign handles returns:
|
|
272
|
+
stock_value_difference is negative for outgoing (Dr COGS/Cr SIH) and
|
|
273
|
+
positive for returns (Cr COGS/Dr SIH).
|
|
274
|
+
"""
|
|
275
|
+
db = get_db()
|
|
276
|
+
|
|
277
|
+
default_expense = db.get_value("Company", doc.company, "default_expense_account")
|
|
278
|
+
stock_account = db.get_value("Company", doc.company, "stock_in_hand_account")
|
|
279
|
+
if not default_expense or not stock_account:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
cost_rows = db.sql(
|
|
283
|
+
'SELECT COALESCE(SUM(stock_value_difference), 0) as diff '
|
|
284
|
+
'FROM "Stock Ledger Entry" '
|
|
285
|
+
'WHERE voucher_type = ? AND voucher_no = ? AND is_cancelled = 0',
|
|
286
|
+
[doc.DOCTYPE, doc.name],
|
|
287
|
+
)
|
|
288
|
+
cost_diff = flt(cost_rows[0]["diff"]) if cost_rows else 0
|
|
289
|
+
if not cost_diff:
|
|
290
|
+
return []
|
|
291
|
+
|
|
292
|
+
cogs_debit = -cost_diff
|
|
293
|
+
sih_credit = -cost_diff
|
|
294
|
+
cost_center = db.get_value("Company", doc.company, "default_cost_center")
|
|
295
|
+
remark = remarks or f"{doc.DOCTYPE} {doc.name}"
|
|
296
|
+
|
|
297
|
+
return [
|
|
298
|
+
_dict(
|
|
299
|
+
account=default_expense,
|
|
300
|
+
debit=flt(cogs_debit, 2),
|
|
301
|
+
debit_in_account_currency=flt(cogs_debit, 2),
|
|
302
|
+
credit=0,
|
|
303
|
+
credit_in_account_currency=0,
|
|
304
|
+
cost_center=cost_center,
|
|
305
|
+
voucher_type=doc.DOCTYPE,
|
|
306
|
+
voucher_no=doc.name,
|
|
307
|
+
posting_date=doc.posting_date,
|
|
308
|
+
company=doc.company,
|
|
309
|
+
remarks=remark,
|
|
310
|
+
),
|
|
311
|
+
_dict(
|
|
312
|
+
account=stock_account,
|
|
313
|
+
debit=0,
|
|
314
|
+
debit_in_account_currency=0,
|
|
315
|
+
credit=flt(sih_credit, 2),
|
|
316
|
+
credit_in_account_currency=flt(sih_credit, 2),
|
|
317
|
+
voucher_type=doc.DOCTYPE,
|
|
318
|
+
voucher_no=doc.name,
|
|
319
|
+
posting_date=doc.posting_date,
|
|
320
|
+
company=doc.company,
|
|
321
|
+
remarks=remark,
|
|
322
|
+
),
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
def reverse_stock_sles(sl_entries):
|
|
326
|
+
"""Flip actual_qty and swap incoming/outgoing rates for cancel-time
|
|
327
|
+
reversal. Returns new dicts so the caller's originals stay intact."""
|
|
328
|
+
reversed_ = []
|
|
329
|
+
for sle in sl_entries:
|
|
330
|
+
flipped = _dict(dict(sle))
|
|
331
|
+
flipped["actual_qty"] = -flt(sle["actual_qty"])
|
|
332
|
+
incoming = sle.get("incoming_rate", 0)
|
|
333
|
+
outgoing = sle.get("outgoing_rate", 0)
|
|
334
|
+
flipped["incoming_rate"] = outgoing
|
|
335
|
+
flipped["outgoing_rate"] = incoming
|
|
336
|
+
reversed_.append(flipped)
|
|
337
|
+
return reversed_
|