feat(report): item-level agricultural filter, FIFO/Average COGS column

Agricultural goods stock report now filters by the per-item agricultural
flag (only agro items inside agro documents count) and grosses up rows to
include VAT. Adds a single "Sold in period (purchase price)" column whose
costing method follows the company's Default Stock Valuation Method
(Moving Average -> weighted average, otherwise FIFO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-24 14:45:00 +00:00
parent 878d2bac83
commit 97b8b4ed56
1 changed files with 184 additions and 30 deletions

View File

@ -1,8 +1,11 @@
# 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
def execute(filters=None):
@ -43,6 +46,15 @@ def get_columns(filters=None):
"fieldtype": "Currency",
"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).
"label": _("Sold in period (purchase price)"),
"fieldname": "sold_purchase_price",
"fieldtype": "Currency",
"width": 220
},
{
# Dövrün sonuna malların (alış qiyməti ilə) qalıq məbləği
"label": _("Closing balance (purchase price)"),
@ -53,31 +65,150 @@ def get_columns(filters=None):
]
def _sum(doctype, company, date_field_from=None, date_field_to=None, before=None):
"""Sum grand_total of submitted agricultural-goods documents of a doctype."""
conditions = "docstatus = 1 AND agricultural_goods = 1"
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 = {}
if company:
conditions += " AND company = %(company)s"
conditions += " AND p.company = %(company)s"
params["company"] = company
if before:
conditions += " AND posting_date < %(before)s"
params["before"] = before
else:
if date_field_from:
conditions += " AND posting_date >= %(from_date)s"
params["from_date"] = date_field_from
if date_field_to:
conditions += " AND posting_date <= %(to_date)s"
params["to_date"] = date_field_to
if to_date:
conditions += " AND p.posting_date <= %(to_date)s"
params["to_date"] = to_date
result = frappe.db.sql(
f"SELECT COALESCE(SUM(grand_total), 0) FROM `tab{doctype}` WHERE {conditions}",
params
)
return float(result[0][0]) if result else 0.0
rows = 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
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
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 _fifo_sold_purchase_price(purchases, sales, start, end):
"""FIFO cost of goods sold within [start, end], valued against purchases.
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
def get_data(filters=None):
@ -85,25 +216,32 @@ def get_data(filters=None):
filters = filters or {}
company = filters.get("company")
from_date = filters.get("from_date")
to_date = filters.get("to_date")
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
# Opening balance = cumulative (purchases - sales) of agricultural goods
# before the start of the period, at purchase/sale price incl. VAT.
purchases = _fetch_rows("Purchase Invoice", "Purchase Invoice Item", company, to_date)
sales = _fetch_rows("Sales Invoice", "Sales Invoice Item", company, to_date)
# Opening balance = cumulative (purchases - sales) before the period start.
if from_date:
opening_purchases = _sum("Purchase Invoice", company, before=from_date)
opening_sales = _sum("Sales Invoice", company, before=from_date)
opening_purchases = _sum_gross(purchases, end=_day_before(from_date))
opening_sales = _sum_gross(sales, end=_day_before(from_date))
opening_balance = opening_purchases - opening_sales
else:
opening_balance = 0.0
# Purchased during the period (purchase price, incl. VAT)
purchased = _sum("Purchase Invoice", company, from_date, to_date)
purchased = _sum_gross(purchases, start=from_date, end=to_date)
sold = _sum_gross(sales, start=from_date, end=to_date)
# Sold during the period (sale price, incl. VAT)
sold = _sum("Sales Invoice", 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)
if valuation_method == "Moving Average":
sold_purchase_price = _avg_sold_purchase_price(purchases, sales, from_date, to_date)
else:
sold_purchase_price = _fifo_sold_purchase_price(purchases, sales, from_date, to_date)
# Simplified closing balance: opening + purchased - sold
# Closing balance keeps the simplified formula (sale price for sold).
closing_balance = opening_balance + purchased - sold
if from_date and to_date:
@ -118,5 +256,21 @@ def get_data(filters=None):
"opening_balance": opening_balance,
"purchased": purchased,
"sold": sold,
"sold_purchase_price": sold_purchase_price,
"closing_balance": closing_balance
}]
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)