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
|
# 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue