refactor(report): source agricultural stock report from Stock Ledger

Rebuild the report on Stock Ledger Entry instead of custom FIFO over
invoices. Opening/closing balances and COGS now come from ERPNext's stock
valuation (which already applies the company's method and includes opening
stock entered via Stock Reconciliation); sale price still comes from Sales
Invoices. Filtering is now item-level (Item.agricultural_goods) and all
amounts are net of VAT, so the trade-markup VAT is not double-counted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-25 08:43:09 +00:00
parent f906536b7f
commit 2d743f2878
1 changed files with 93 additions and 184 deletions

View File

@ -1,8 +1,6 @@
# Copyright (c) 2026, Company and contributors
# For license information, please see license.txt
from collections import defaultdict, deque
import frappe
from frappe import _
from frappe.utils import flt, getdate
@ -17,7 +15,7 @@ def execute(filters=None):
def get_columns(filters=None):
"""Get report columns definition"""
"""Get report columns definition (Cədvəl 18(1) — ƏDV - Kənd təsərrüfatı)."""
return [
{
"label": _("Period"),
@ -47,9 +45,7 @@ def get_columns(filters=None):
"width": 200
},
{
# Dövr ərzində satılmış malların (alış qiyməti ilə) məbləği.
# Hesablama metodu şirkətin Default Stock Valuation Method-una görə
# seçilir (Moving Average -> orta, əks halda FIFO).
# Dövr ərzində satılmış malların (alış qiyməti ilə) məbləği
"label": _("Sold in period (purchase price)"),
"fieldname": "sold_purchase_price",
"fieldtype": "Currency",
@ -70,8 +66,7 @@ def get_columns(filters=None):
"width": 220
},
{
# Cari hesabat dövründə satılmış mallara görə ticarət əlavəsinin
# ƏDV məbləği = ticarət əlavəsi * 18%
# Cari hesabat dövründə satılmış mallara görə ticarət əlavəsinin ƏDV məbləği
"label": _("VAT on trade markup"),
"fieldname": "trade_markup_vat",
"fieldtype": "Currency",
@ -80,198 +75,122 @@ def get_columns(filters=None):
]
def _fetch_rows(parent_dt, child_dt, company, to_date):
"""Fetch agricultural item rows of submitted agricultural documents.
Only rows whose Item is itself flagged as agricultural goods are returned.
Each row is grossed up to include VAT proportionally (grand_total/net_total).
"""
conditions = "p.docstatus = 1 AND p.agricultural_goods = 1 AND it.agricultural_goods = 1"
params = {}
def _company_cond(company, params, alias):
if company:
conditions += " AND p.company = %(company)s"
params["company"] = company
return f" AND {alias}.company = %(company)s"
return ""
def _stock_value_as_of(company, date):
"""Total stock value (purchase price) of agricultural items as of a date.
Sums the running stock_value of the latest Stock Ledger Entry per
item+warehouse on or before the given date.
"""
if not date:
return 0.0
params = {"date": date}
company_cond = _company_cond(company, params, "sle")
result = frappe.db.sql(f"""
SELECT COALESCE(SUM(t.stock_value), 0)
FROM (
SELECT
sle.stock_value,
ROW_NUMBER() OVER (
PARTITION BY sle.item_code, sle.warehouse
ORDER BY sle.posting_date DESC, sle.posting_time DESC, sle.creation DESC
) AS rn
FROM `tabStock Ledger Entry` sle
INNER JOIN `tabItem` it
ON it.name = sle.item_code AND it.agricultural_goods = 1
WHERE sle.is_cancelled = 0
AND sle.posting_date <= %(date)s
{company_cond}
) t
WHERE t.rn = 1
""", params)
return flt(result[0][0]) if result else 0.0
def _movements(company, from_date, to_date):
"""Incoming (purchased) and outgoing (COGS) stock value within the period."""
params = {}
conditions = "sle.is_cancelled = 0 AND it.agricultural_goods = 1"
conditions += _company_cond(company, params, "sle")
if from_date:
conditions += " AND sle.posting_date >= %(from_date)s"
params["from_date"] = from_date
if to_date:
conditions += " AND p.posting_date <= %(to_date)s"
conditions += " AND sle.posting_date <= %(to_date)s"
params["to_date"] = to_date
rows = frappe.db.sql(f"""
result = frappe.db.sql(f"""
SELECT
p.name AS parent,
p.posting_date AS posting_date,
c.idx AS idx,
c.item_code AS item_code,
c.qty AS qty,
c.net_amount AS net_amount,
p.grand_total AS grand_total,
p.net_total AS net_total
FROM `tab{child_dt}` c
JOIN `tab{parent_dt}` p ON p.name = c.parent
JOIN `tabItem` it ON it.name = c.item_code
COALESCE(SUM(CASE WHEN sle.actual_qty > 0 THEN sle.stock_value_difference ELSE 0 END), 0) AS purchased,
COALESCE(SUM(CASE WHEN sle.actual_qty < 0 THEN -sle.stock_value_difference ELSE 0 END), 0) AS cogs
FROM `tabStock Ledger Entry` sle
INNER JOIN `tabItem` it ON it.name = sle.item_code
WHERE {conditions}
ORDER BY p.posting_date, p.name, c.idx
""", params, as_dict=1)
for r in rows:
# Gross up the row to include its share of document-level VAT/charges.
factor = (flt(r.grand_total) / flt(r.net_total)) if flt(r.net_total) else 1.0
r["gross"] = flt(r.net_amount) * factor
r["unit"] = (r["gross"] / flt(r.qty)) if flt(r.qty) else 0.0
return rows
row = result[0] if result else {}
return flt(row.get("purchased")), flt(row.get("cogs"))
def _sum_gross(rows, start=None, end=None):
"""Sum grossed-up amount of rows whose posting_date is within [start, end]."""
total = 0.0
for r in rows:
d = getdate(r.posting_date)
if start and d < start:
continue
if end and d > end:
continue
total += r["gross"]
return total
def _sold_sale_price(company, from_date, to_date):
"""Net sale value (purchase price excluded) of agricultural items sold."""
params = {}
conditions = "si.docstatus = 1 AND it.agricultural_goods = 1"
conditions += _company_cond(company, params, "si")
if from_date:
conditions += " AND si.posting_date >= %(from_date)s"
params["from_date"] = from_date
if to_date:
conditions += " AND si.posting_date <= %(to_date)s"
params["to_date"] = to_date
def _fifo_sold_purchase_price(purchases, sales, start, end):
"""FIFO cost of goods sold within [start, end], valued against purchases.
result = frappe.db.sql(f"""
SELECT COALESCE(SUM(sii.net_amount), 0)
FROM `tabSales Invoice Item` sii
INNER JOIN `tabSales Invoice` si ON si.name = sii.parent
INNER JOIN `tabItem` it ON it.name = sii.item_code
WHERE {conditions}
""", params)
Per item, purchases form FIFO layers consumed by sales in chronological
order. Sales before the period still consume layers (to advance the FIFO
position) but do not contribute to the reported cost.
"""
layers = defaultdict(deque) # item_code -> deque([qty, unit_cost])
events = []
for r in purchases:
events.append((getdate(r.posting_date), 0, r)) # purchases first on a day
for r in sales:
events.append((getdate(r.posting_date), 1, r))
events.sort(key=lambda e: (e[0], e[1]))
cogs = 0.0
for d, kind, r in events:
item = r["item_code"]
if kind == 0:
qty = flt(r.qty)
if qty:
layers[item].append([qty, r["unit"]])
continue
# sale: consume layers
remaining = flt(r.qty)
consumed_cost = 0.0
dq = layers[item]
while remaining > 1e-9 and dq:
lqty, lunit = dq[0]
take = min(lqty, remaining)
consumed_cost += take * lunit
lqty -= take
remaining -= take
if lqty <= 1e-9:
dq.popleft()
else:
dq[0][0] = lqty
in_period = (not start or d >= start) and (not end or d <= end)
if in_period:
cogs += consumed_cost
return cogs
def _avg_sold_purchase_price(purchases, sales, start, end):
"""Moving weighted-average cost of goods sold within [start, end].
Per item, all stock is blended into a single pool (quantity + value); the
average unit cost is recomputed on every purchase. Sales are valued at the
current average. Like FIFO, sales before the period still consume stock
(to keep the average correct) but do not contribute to the reported cost.
"""
# item_code -> [stock_qty, stock_value]
pools = defaultdict(lambda: [0.0, 0.0])
events = []
for r in purchases:
events.append((getdate(r.posting_date), 0, r)) # purchases first on a day
for r in sales:
events.append((getdate(r.posting_date), 1, r))
events.sort(key=lambda e: (e[0], e[1]))
cogs = 0.0
for d, kind, r in events:
item = r["item_code"]
pool = pools[item]
if kind == 0:
qty = flt(r.qty)
pool[0] += qty
pool[1] += qty * r["unit"]
continue
# sale: value at current average, capped at available stock
qty = flt(r.qty)
avg = (pool[1] / pool[0]) if pool[0] > 1e-9 else 0.0
take = min(qty, pool[0]) if pool[0] > 0 else 0.0
consumed_cost = take * avg
pool[0] -= take
pool[1] -= consumed_cost
in_period = (not start or d >= start) and (not end or d <= end)
if in_period:
cogs += consumed_cost
return cogs
return flt(result[0][0]) if result else 0.0
def get_data(filters=None):
"""Get report data (single row for the selected period)."""
"""Single row for the selected period, sourced from the Stock Ledger.
Opening/closing balances and cost of goods sold come from ERPNext's Stock
Ledger (which already applies the company's valuation method and includes
opening stock entered via Stock Reconciliation). Sale price comes from
Sales Invoices. All amounts are net of VAT.
"""
filters = filters or {}
company = filters.get("company")
from_date = getdate(filters["from_date"]) if filters.get("from_date") else None
to_date = getdate(filters["to_date"]) if filters.get("to_date") else None
purchases = _fetch_rows("Purchase Invoice", "Purchase Invoice Item", company, to_date)
sales = _fetch_rows("Sales Invoice", "Sales Invoice Item", company, to_date)
opening_balance = _stock_value_as_of(company, _day_before(from_date)) if from_date else 0.0
purchased, cogs = _movements(company, from_date, to_date)
sold = _sold_sale_price(company, from_date, to_date)
# Pick the cost-of-goods-sold method from the company's Default Stock
# Valuation Method. Moving Average -> weighted average, otherwise FIFO.
valuation_method = _get_valuation_method(company)
def cogs(start, end):
if valuation_method == "Moving Average":
return _avg_sold_purchase_price(purchases, sales, start, end)
return _fifo_sold_purchase_price(purchases, sales, start, end)
# Opening balance = cost (purchase price) of stock still on hand at the
# start of the period = all purchases before the period minus the COST of
# all goods sold before it. Subtracting sales at COGS (not sale price)
# keeps it consistent with the closing balance, so each period's opening
# equals the previous period's closing.
if from_date:
opening_purchases = _sum_gross(purchases, end=_day_before(from_date))
opening_cogs = cogs(None, _day_before(from_date))
opening_balance = opening_purchases - opening_cogs
if to_date:
closing_balance = _stock_value_as_of(company, to_date)
else:
opening_balance = 0.0
closing_balance = opening_balance + purchased - cogs
purchased = _sum_gross(purchases, start=from_date, end=to_date)
sold = _sum_gross(sales, start=from_date, end=to_date)
sold_purchase_price = cogs(from_date, to_date)
# Closing balance at purchase price = opening + purchased - cost of goods
# sold (COGS, valued per the company's stock valuation method).
closing_balance = opening_balance + purchased - sold_purchase_price
# Cari hesabat dövründə satılmış mallara görə ticarət əlavəsi:
# sold (sale price) - (opening + purchased - closing)
# The parenthesised term is the COGS, so this is sale value minus cost.
trade_markup = sold - (opening_balance + purchased - closing_balance)
# ƏDV on the trade markup (18%).
# Cari hesabat dövründə satılmış mallara görə ticarət əlavəsi = sale - cost.
trade_markup = sold - cogs
trade_markup_vat = trade_markup * 0.18
if from_date and to_date:
@ -286,23 +205,13 @@ def get_data(filters=None):
"opening_balance": opening_balance,
"purchased": purchased,
"sold": sold,
"sold_purchase_price": sold_purchase_price,
"sold_purchase_price": cogs,
"closing_balance": closing_balance,
"trade_markup": trade_markup,
"trade_markup_vat": trade_markup_vat
}]
def _get_valuation_method(company):
"""Resolve the Default Stock Valuation Method (per company, else global)."""
method = None
if company:
method = frappe.db.get_value("Company", company, "valuation_method")
if not method:
method = frappe.db.get_single_value("Stock Settings", "valuation_method")
return method or "FIFO"
def _day_before(d):
from datetime import timedelta
return d - timedelta(days=1)