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:
parent
878d2bac83
commit
97b8b4ed56
|
|
@ -1,8 +1,11 @@
|
||||||
# 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
|
||||||
|
|
||||||
|
|
||||||
def execute(filters=None):
|
def execute(filters=None):
|
||||||
|
|
@ -43,6 +46,15 @@ def get_columns(filters=None):
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"width": 200
|
"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
|
# Dövrün sonuna malların (alış qiyməti ilə) qalıq məbləği
|
||||||
"label": _("Closing balance (purchase price)"),
|
"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):
|
def _fetch_rows(parent_dt, child_dt, company, to_date):
|
||||||
"""Sum grand_total of submitted agricultural-goods documents of a doctype."""
|
"""Fetch agricultural item rows of submitted agricultural documents.
|
||||||
conditions = "docstatus = 1 AND agricultural_goods = 1"
|
|
||||||
|
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 = {}
|
params = {}
|
||||||
|
|
||||||
if company:
|
if company:
|
||||||
conditions += " AND company = %(company)s"
|
conditions += " AND p.company = %(company)s"
|
||||||
params["company"] = company
|
params["company"] = company
|
||||||
|
|
||||||
if before:
|
if to_date:
|
||||||
conditions += " AND posting_date < %(before)s"
|
conditions += " AND p.posting_date <= %(to_date)s"
|
||||||
params["before"] = before
|
params["to_date"] = to_date
|
||||||
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
|
|
||||||
|
|
||||||
result = frappe.db.sql(
|
rows = frappe.db.sql(f"""
|
||||||
f"SELECT COALESCE(SUM(grand_total), 0) FROM `tab{doctype}` WHERE {conditions}",
|
SELECT
|
||||||
params
|
p.name AS parent,
|
||||||
)
|
p.posting_date AS posting_date,
|
||||||
return float(result[0][0]) if result else 0.0
|
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):
|
def get_data(filters=None):
|
||||||
|
|
@ -85,25 +216,32 @@ def get_data(filters=None):
|
||||||
filters = filters or {}
|
filters = filters or {}
|
||||||
|
|
||||||
company = filters.get("company")
|
company = filters.get("company")
|
||||||
from_date = filters.get("from_date")
|
from_date = getdate(filters["from_date"]) if filters.get("from_date") else None
|
||||||
to_date = filters.get("to_date")
|
to_date = getdate(filters["to_date"]) if filters.get("to_date") else None
|
||||||
|
|
||||||
# Opening balance = cumulative (purchases - sales) of agricultural goods
|
purchases = _fetch_rows("Purchase Invoice", "Purchase Invoice Item", company, to_date)
|
||||||
# before the start of the period, at purchase/sale price incl. VAT.
|
sales = _fetch_rows("Sales Invoice", "Sales Invoice Item", company, to_date)
|
||||||
|
|
||||||
|
# Opening balance = cumulative (purchases - sales) before the period start.
|
||||||
if from_date:
|
if from_date:
|
||||||
opening_purchases = _sum("Purchase Invoice", company, before=from_date)
|
opening_purchases = _sum_gross(purchases, end=_day_before(from_date))
|
||||||
opening_sales = _sum("Sales Invoice", company, before=from_date)
|
opening_sales = _sum_gross(sales, end=_day_before(from_date))
|
||||||
opening_balance = opening_purchases - opening_sales
|
opening_balance = opening_purchases - opening_sales
|
||||||
else:
|
else:
|
||||||
opening_balance = 0.0
|
opening_balance = 0.0
|
||||||
|
|
||||||
# Purchased during the period (purchase price, incl. VAT)
|
purchased = _sum_gross(purchases, start=from_date, end=to_date)
|
||||||
purchased = _sum("Purchase Invoice", company, from_date, to_date)
|
sold = _sum_gross(sales, start=from_date, end=to_date)
|
||||||
|
|
||||||
# Sold during the period (sale price, incl. VAT)
|
# Pick the cost-of-goods-sold method from the company's Default Stock
|
||||||
sold = _sum("Sales Invoice", company, from_date, to_date)
|
# 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
|
closing_balance = opening_balance + purchased - sold
|
||||||
|
|
||||||
if from_date and to_date:
|
if from_date and to_date:
|
||||||
|
|
@ -118,5 +256,21 @@ 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,
|
||||||
"closing_balance": closing_balance
|
"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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue