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.
Files changed (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. 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_