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 ffa9f3a..a7baa27 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,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)