diff --git a/taxes_az/taxes_az/report/agricultural_goods_stock_report/agricultural_goods_stock_report.py b/taxes_az/taxes_az/report/agricultural_goods_stock_report/agricultural_goods_stock_report.py index e32821c..5e61048 100644 --- a/taxes_az/taxes_az/report/agricultural_goods_stock_report/agricultural_goods_stock_report.py +++ b/taxes_az/taxes_az/report/agricultural_goods_stock_report/agricultural_goods_stock_report.py @@ -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)