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 # Copyright (c) 2026, Company and contributors
# For license information, please see license.txt # For license information, please see license.txt
from collections import defaultdict, deque
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import flt, getdate from frappe.utils import flt, getdate
@ -17,7 +15,7 @@ def execute(filters=None):
def get_columns(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 [ return [
{ {
"label": _("Period"), "label": _("Period"),
@ -47,9 +45,7 @@ def get_columns(filters=None):
"width": 200 "width": 200
}, },
{ {
# Dövr ərzində satılmış malların (alış qiyməti ilə) məbləği. # 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).
"label": _("Sold in period (purchase price)"), "label": _("Sold in period (purchase price)"),
"fieldname": "sold_purchase_price", "fieldname": "sold_purchase_price",
"fieldtype": "Currency", "fieldtype": "Currency",
@ -70,8 +66,7 @@ def get_columns(filters=None):
"width": 220 "width": 220
}, },
{ {
# Cari hesabat dövründə satılmış mallara görə ticarət əlavəsinin # Cari hesabat dövründə satılmış mallara görə ticarət əlavəsinin ƏDV məbləği
# ƏDV məbləği = ticarət əlavəsi * 18%
"label": _("VAT on trade markup"), "label": _("VAT on trade markup"),
"fieldname": "trade_markup_vat", "fieldname": "trade_markup_vat",
"fieldtype": "Currency", "fieldtype": "Currency",
@ -80,198 +75,122 @@ def get_columns(filters=None):
] ]
def _fetch_rows(parent_dt, child_dt, company, to_date): def _company_cond(company, params, alias):
"""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 = {}
if company: if company:
conditions += " AND p.company = %(company)s"
params["company"] = company 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: if to_date:
conditions += " AND p.posting_date <= %(to_date)s" conditions += " AND sle.posting_date <= %(to_date)s"
params["to_date"] = to_date params["to_date"] = to_date
rows = frappe.db.sql(f""" result = frappe.db.sql(f"""
SELECT SELECT
p.name AS parent, COALESCE(SUM(CASE WHEN sle.actual_qty > 0 THEN sle.stock_value_difference ELSE 0 END), 0) AS purchased,
p.posting_date AS posting_date, COALESCE(SUM(CASE WHEN sle.actual_qty < 0 THEN -sle.stock_value_difference ELSE 0 END), 0) AS cogs
c.idx AS idx, FROM `tabStock Ledger Entry` sle
c.item_code AS item_code, INNER JOIN `tabItem` it ON it.name = sle.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
WHERE {conditions} WHERE {conditions}
ORDER BY p.posting_date, p.name, c.idx
""", params, as_dict=1) """, params, as_dict=1)
for r in rows: row = result[0] if result else {}
# Gross up the row to include its share of document-level VAT/charges. return flt(row.get("purchased")), flt(row.get("cogs"))
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
def _sum_gross(rows, start=None, end=None): def _sold_sale_price(company, from_date, to_date):
"""Sum grossed-up amount of rows whose posting_date is within [start, end].""" """Net sale value (purchase price excluded) of agricultural items sold."""
total = 0.0 params = {}
for r in rows: conditions = "si.docstatus = 1 AND it.agricultural_goods = 1"
d = getdate(r.posting_date) conditions += _company_cond(company, params, "si")
if start and d < start:
continue
if end and d > end:
continue
total += r["gross"]
return total
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): result = frappe.db.sql(f"""
"""FIFO cost of goods sold within [start, end], valued against purchases. 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 return flt(result[0][0]) if result else 0.0
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
def get_data(filters=None): 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 {} filters = filters or {}
company = filters.get("company") company = filters.get("company")
from_date = getdate(filters["from_date"]) if filters.get("from_date") else None 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 to_date = getdate(filters["to_date"]) if filters.get("to_date") else None
purchases = _fetch_rows("Purchase Invoice", "Purchase Invoice Item", company, to_date) opening_balance = _stock_value_as_of(company, _day_before(from_date)) if from_date else 0.0
sales = _fetch_rows("Sales Invoice", "Sales Invoice Item", company, to_date) 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 if to_date:
# Valuation Method. Moving Average -> weighted average, otherwise FIFO. closing_balance = _stock_value_as_of(company, to_date)
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
else: else:
opening_balance = 0.0 closing_balance = opening_balance + purchased - cogs
purchased = _sum_gross(purchases, start=from_date, end=to_date) # Cari hesabat dövründə satılmış mallara görə ticarət əlavəsi = sale - cost.
sold = _sum_gross(sales, start=from_date, end=to_date) trade_markup = sold - cogs
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%).
trade_markup_vat = trade_markup * 0.18 trade_markup_vat = trade_markup * 0.18
if from_date and to_date: if from_date and to_date:
@ -286,23 +205,13 @@ def get_data(filters=None):
"opening_balance": opening_balance, "opening_balance": opening_balance,
"purchased": purchased, "purchased": purchased,
"sold": sold, "sold": sold,
"sold_purchase_price": sold_purchase_price, "sold_purchase_price": cogs,
"closing_balance": closing_balance, "closing_balance": closing_balance,
"trade_markup": trade_markup, "trade_markup": trade_markup,
"trade_markup_vat": trade_markup_vat "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): def _day_before(d):
from datetime import timedelta from datetime import timedelta
return d - timedelta(days=1) return d - timedelta(days=1)