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:
parent
f906536b7f
commit
2d743f2878
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue