Compare commits

..

8 Commits

Author SHA1 Message Date
MochaMind
7362ef043e fix: Bosnian translations 2026-07-02 02:09:34 +05:30
MochaMind
57f7ed8d82 fix: Croatian translations 2026-07-02 02:09:29 +05:30
MochaMind
1ad1570e3e fix: Swedish translations 2026-07-02 02:09:22 +05:30
MochaMind
5d762c6e9e fix: Persian translations 2026-07-02 02:09:16 +05:30
MochaMind
af3a08dd9e fix: Bosnian translations 2026-07-01 02:14:26 +05:30
MochaMind
e52562f711 fix: Croatian translations 2026-07-01 02:14:19 +05:30
MochaMind
f5a2503206 fix: Swedish translations 2026-07-01 02:14:14 +05:30
MochaMind
ffec95263c fix: Persian translations 2026-07-01 02:14:04 +05:30
134 changed files with 2276 additions and 7222 deletions

View File

@@ -177,16 +177,6 @@ These are auto-handled by the framework and are **not** breaks:
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work
must survive.** A full rollback un-poisons the txn but also discards every row the handler committed
*before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB
regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback
safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A
full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway),
(b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be
**atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use
a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract:
do **not** return the doc when the savepoint was rolled back.
---

View File

@@ -105,11 +105,6 @@ jobs:
FRAPPE_BRANCH: develop
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Warm up test data
run: |
cd ~/frappe-bench/
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
- name: Stop DB and stage datadir
run: |
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
@@ -137,7 +132,7 @@ jobs:
compression-level: 0
test:
name: Python Unit Tests
name: Python Unit Tests (PG)
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60

File diff suppressed because one or more lines are too long

View File

@@ -88,6 +88,7 @@ pull_request_rules:
actions:
merge:
method: squash
commit_message_format:
title: pr-title
body: pr-body
commit_message_template: |
{{ title }} (#{{ number }})
{{ body }}

View File

@@ -14,32 +14,33 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.1",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.15.0",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.0",
"jotai-family": "^1.0.1",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-currency-input-field": "^4.0.5",
"react-day-picker": "9.14.0",
"react-dom": "^19.2.7",
"react-dom": "^19.2.6",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
@@ -51,15 +52,15 @@
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.1"
"typescript-eslint": "^8.48.0"
}
}

View File

@@ -1,5 +1,5 @@
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'

View File

@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { Link, useNavigate } from 'react-router'
import { Link, useNavigate } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { Progress } from '@/components/ui/progress'
import { useSetAtom } from 'jotai'

File diff suppressed because it is too large Load Diff

View File

@@ -179,12 +179,7 @@ def normalize_ctx_input(T: type) -> callable:
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(
func,
assigned=(
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
),
)
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
if isinstance(ctx, Document):
ctx = T(**ctx.as_dict())

View File

@@ -829,9 +829,7 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
if amount_format == 'Amount column has "CR"/"DR" values':
amount = transaction_row.get("amount")
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
float_amount = abs(get_float_amount(amount) or 0)
float_amount = get_float_amount(amount)
if "cr" in amount.lower():
return 0, float_amount
else:
@@ -934,18 +932,14 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if reader.is_encrypted:
# Try opening the PDF with a password - if no password is provided, try with a blank password
if not password:
password = ""
if not reader.decrypt(password):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
if reader.is_encrypted and (not password or not reader.decrypt(password)):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
tables = []

View File

@@ -107,9 +107,6 @@ def auto_create_fiscal_year():
)
for d in fiscal_year:
# savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the
# statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration
frappe.db.savepoint("auto_create_fiscal_year")
try:
current_fy = frappe.get_doc("Fiscal Year", d[0])
@@ -130,7 +127,7 @@ def auto_create_fiscal_year():
new_fy.insert(ignore_permissions=True)
except frappe.NameError:
frappe.db.rollback(save_point="auto_create_fiscal_year")
pass
def get_from_and_to_date(fiscal_year):

View File

@@ -471,25 +471,6 @@ def on_doctype_update():
frappe.db.add_index("GL Entry", ["posting_date", "company"])
frappe.db.add_index("GL Entry", ["party_type", "party"])
if frappe.db.db_type == "postgres":
# Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial
# Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company.
# `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are
# added only on postgres to avoid dead write overhead on this insert-hot table.
frappe.db.add_index(
"GL Entry",
["company", "posting_date", "account"],
index_name="gle_active_detail",
where="is_cancelled = 0",
)
frappe.db.add_index(
"GL Entry",
["company", "account", "posting_date"],
index_name="gle_active_cover",
where="is_cancelled = 0",
include=["debit", "credit"],
)
def rename_gle_sle_docs():
for doctype in ["GL Entry", "Stock Ledger Entry"]:

View File

@@ -2795,9 +2795,6 @@ def get_open_payment_requests_for_references(references=None):
.where(PR.docstatus == 1)
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
# unique tiebreaker so PRs sharing a transaction_date allocate in the same order on both engines
.orderby(PR.creation, order=frappe.qb.asc)
.orderby(PR.name, order=frappe.qb.asc)
).run(as_dict=True)
if not response:

View File

@@ -663,6 +663,7 @@ class POSInvoice(SalesInvoice):
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
@@ -735,7 +736,7 @@ class POSInvoice(SalesInvoice):
for item in self.get("items"):
if item.get("item_code"):
profile_details = get_pos_profile_item_details_(
frappe._dict(item.as_dict()), profile.get("company"), profile
ItemDetailsCtx(item.as_dict()), profile.get("company"), profile
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):

View File

@@ -126,13 +126,13 @@ class POSService:
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
from erpnext.stock.get_item_details import get_pos_profile_item_details_
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
for item in self.doc.get("items"):
if not item.get("item_code"):
continue
profile_details = get_pos_profile_item_details_(
frappe._dict(item.as_dict()), pos, pos, update_data=True
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
"modified": "2026-07-01 13:37:41.185347",
"modified": "2026-06-25 12:03:36.559152",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -40,6 +40,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"synced_report": 0,
"timeout": 0
}

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 5,
"is_standard": "Yes",
"modified": "2026-07-01 13:37:44.167999",
"modified": "2026-06-25 12:03:28.812092",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -34,6 +34,6 @@
"role": "Accounts User"
}
],
"snapshot_report": 0,
"synced_report": 0,
"timeout": 0
}

View File

@@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
return chart
def execute_snapshot_report(filters):
def execute_synced_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):

View File

@@ -2,104 +2,26 @@
# For license information, please see license.txt
import frappe
from frappe.utils import nowdate
from erpnext.accounts.doctype.budget.test_budget import make_budget
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
ACCOUNT = "_Test Account Cost for Goods Sold - _TC"
COST_CENTER = "_Test Cost Center - _TC"
COST_CENTER_2 = "_Test Cost Center 2 - _TC"
class TestBudgetVarianceReport(ERPNextTestSuite):
def setUp(self):
self.fy = get_fiscal_year(nowdate())[0]
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_fiscal_year": self.fy,
"to_fiscal_year": self.fy,
"period": "Yearly",
"budget_against": "Cost Center",
**extra,
}
)
return execute(filters)[1]
def report_row(self, data, dimension, account=ACCOUNT):
return next(row for row in data if row["budget_against"] == dimension and row["account"] == account)
def field(self, label):
return frappe.scrub(f"{label} {self.fy}")
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
# both MariaDB and postgres.
company = frappe.db.get_value("Company", {}, "name")
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
columns, *_rest = execute(
frappe._dict(
{
"company": "_Test Company",
"from_fiscal_year": self.fy,
"to_fiscal_year": self.fy,
"company": company,
"from_fiscal_year": fy,
"to_fiscal_year": fy,
"period": "Yearly",
"budget_against": "Cost Center",
}
)
)
self.assertTrue(columns)
def test_budget_amount_shown_with_zero_actual(self):
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
row = self.report_row(self.run_report(), COST_CENTER)
self.assertEqual(row[self.field("Budget")], 120000)
self.assertEqual(row[self.field("Actual")], 0)
self.assertEqual(row[self.field("Variance")], 120000)
def test_actual_expense_updates_actual_and_variance(self):
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
# book an actual expense well within the annual budget so the "Stop" action does not block it
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
row = self.report_row(self.run_report(), COST_CENTER)
self.assertEqual(row[self.field("Actual")], 50000)
self.assertEqual(row[self.field("Variance")], 70000) # 120000 - 50000
def test_budget_against_filter_limits_dimensions(self):
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER_2, budget_amount=80000, submit_budget=1
)
data = self.run_report(budget_against_filter=[COST_CENTER])
dimensions = {row["budget_against"] for row in data}
self.assertEqual(dimensions, {COST_CENTER})
def test_monthly_period_totals(self):
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
row = self.report_row(self.run_report(period="Monthly"), COST_CENTER)
# totals roll up the per-month columns across the year
self.assertEqual(row["total_budget"], 120000)
self.assertEqual(row["total_actual"], 50000)
self.assertEqual(row["total_variance"], 70000)
def test_no_budget_returns_no_rows(self):
# a dimension without any budget produces no report rows
data = self.run_report(budget_against_filter=["_Test Write Off Cost Center - _TC"])
self.assertEqual(data, [])

View File

@@ -1,89 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.delivered_items_to_be_billed.delivered_items_to_be_billed import execute
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestDeliveredItemsToBeBilled(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": "2026-06-30",
}
)
filters.update(extra)
return execute(filters)[1]
def stock_up_item(self):
make_stock_entry(
item_code="_Test Item",
target="Stores - _TC",
qty=20,
basic_rate=100,
posting_date="2026-05-25",
)
def test_unbilled_delivery_note_appears(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-06-01",
)
rows = self.run_report(delivery_note=dn.name)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.name, dn.name)
self.assertEqual(row.customer, "_Test Customer")
self.assertEqual(row.item_code, "_Test Item")
self.assertEqual(row.amount, 1500)
self.assertEqual(row.billed_amount, 0)
self.assertEqual(row.returned_amount, 0)
self.assertEqual(row.pending_amount, 1500)
def test_fully_billed_delivery_note_drops_out(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-06-01",
)
self.assertEqual(len(self.run_report(delivery_note=dn.name)), 1)
si = make_sales_invoice(dn.name)
si.posting_date = "2026-06-02"
si.set_posting_time = 1
si.insert()
si.submit()
self.assertEqual(self.run_report(delivery_note=dn.name), [])
def test_date_filter_excludes_later_delivery_notes(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-07-15",
)
rows = self.run_report(delivery_note=dn.name, posting_date="2026-06-30")
self.assertEqual(rows, [])

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-07-01 13:36:06.682661",
"modified": "2026-06-22 13:38:35.057216",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"synced_report": 0,
"timeout": 0
}

View File

@@ -820,7 +820,7 @@ def get_columns(filters):
return columns
def execute_snapshot_report(filters):
def execute_synced_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):

View File

@@ -1,85 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.gross_and_net_profit_report.gross_and_net_profit_report import execute
from erpnext.tests.utils import ERPNextTestSuite
BANK = "_Test Bank - _TC"
INCOME_PARENT = "Income - _TC"
EXPENSE_PARENT = "Expenses - _TC"
# bootstrap leaf accounts that already have include_in_gross = 0 (no creation needed)
NON_GROSS_INCOME = "_Test Account Sales - _TC"
NON_GROSS_EXPENSE = "_Test Account Cost for Goods Sold - _TC"
# an isolated fiscal year so other accounts contribute nothing to the totals
FY = "_Test Fiscal Year 2049"
DATE = "2049-06-01"
class TestGrossAndNetProfitReport(ERPNextTestSuite):
def run_report(self, from_fiscal_year=FY, to_fiscal_year=FY):
filters = frappe._dict(
{
"company": "_Test Company",
"filter_based_on": "Fiscal Year",
"from_fiscal_year": from_fiscal_year,
"to_fiscal_year": to_fiscal_year,
"period_start_date": "2049-01-01",
"period_end_date": "2049-12-31",
"periodicity": "Yearly",
"accumulated_values": 0,
"presentation_currency": None,
}
)
return execute(filters)[1]
def make_account(self, name, parent, include_in_gross):
account = create_account(account_name=name, parent_account=parent, company="_Test Company")
frappe.db.set_value("Account", account, "include_in_gross", include_in_gross)
return account
def book_income(self, account, amount):
make_journal_entry(BANK, account, amount, posting_date=DATE, submit=True)
def book_expense(self, account, amount):
make_journal_entry(account, BANK, amount, posting_date=DATE, submit=True)
def report_row(self, data, account):
return next(row for row in data if row.get("account") == account)
def test_gross_profit_excludes_non_gross_accounts(self):
# reuse bootstrap accounts for the non-gross (include_in_gross = 0) side
gross_income = self.make_account("_Test GNP Gross Income", INCOME_PARENT, include_in_gross=1)
gross_expense = self.make_account("_Test GNP Gross Expense", EXPENSE_PARENT, include_in_gross=1)
self.book_income(gross_income, 10000)
self.book_income(NON_GROSS_INCOME, 2000)
self.book_expense(gross_expense, 4000)
self.book_expense(NON_GROSS_EXPENSE, 1000)
data = self.run_report()
# gross profit only counts include_in_gross accounts: 10000 - 4000
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 6000)
# net profit counts everything: (10000 + 2000) - (4000 + 1000)
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 7000)
def test_net_profit_equals_gross_when_all_included(self):
income = self.make_account("_Test GNP All Income", INCOME_PARENT, include_in_gross=1)
expense = self.make_account("_Test GNP All Expense", EXPENSE_PARENT, include_in_gross=1)
self.book_income(income, 9000)
self.book_expense(expense, 5000)
data = self.run_report()
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 4000)
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 4000)
def test_nothing_included_in_gross_when_no_entries(self):
# a fiscal year with no income/expense entries yields the placeholder row
data = self.run_report(
from_fiscal_year="_Test Fiscal Year 2048", to_fiscal_year="_Test Fiscal Year 2048"
)
self.assertEqual(data[0]["account"], "'Nothing is included in gross'")

View File

@@ -562,12 +562,7 @@ class GrossProfitGenerator:
row.base_amount = packed_item.base_amount
# get buying amount
if row.is_debit_note:
# Rate adjustment debit notes have no stock movement, so buying amount is zero
if not grouped_by_invoice:
row.qty = 0
row.buying_amount = 0
elif row.item_code in product_bundles:
if row.item_code in product_bundles:
row.buying_amount = flt(
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
self.currency_precision,
@@ -965,7 +960,6 @@ class GrossProfitGenerator:
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
SalesInvoice.is_debit_note,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
@@ -1146,7 +1140,6 @@ class GrossProfitGenerator:
"posting_time": row.posting_time,
"project": row.project,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
@@ -1185,7 +1178,6 @@ class GrossProfitGenerator:
"description": item.description,
"warehouse": item.warehouse or row.warehouse,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"item_group": "",
"brand": "",
"dn_detail": row.dn_detail,

View File

@@ -700,160 +700,6 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertIsNone(data[1].buying_rate)
self.assertEqual(data[1]["gross_profit_%"], 20)
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
"""Create a rate adjustment debit note with no stock movement."""
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
if item_code:
dn.items[0].item_code = item_code
dn.items[0].item_name = item_code
dn.is_debit_note = 1
dn.return_against = against_invoice.name
dn.items[0].allow_zero_valuation_rate = 1
return dn.save().submit()
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
"""
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
since there is no stock movement. Gross profit equals the adjustment amount
and gross profit % equals 100%.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
dn_item_rows = [
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
]
self.assertEqual(len(dn_item_rows), 1)
dn_row = dn_item_rows[0]
self.assertEqual(dn_row.buying_amount, 0.0)
self.assertEqual(dn_row.selling_amount, 20.0)
self.assertEqual(dn_row.gross_profit, 20.0)
self.assertEqual(dn_row["gross_profit_%"], 100.0)
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
"""
The original invoice's GP should be derived solely from its own selling
amount and COGS — the rate adjustment debit note must not alter it.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
self.assertEqual(len(sinv_item_rows), 1)
sinv_row = sinv_item_rows[0]
self.assertEqual(sinv_row.selling_amount, 200.0)
self.assertEqual(sinv_row.buying_amount, 100.0)
self.assertEqual(sinv_row.gross_profit, 100.0)
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
def test_debit_note_qty_not_inflated_in_grouped_report(self):
"""
When grouped by Item Code, the debit note (qty=0) must not inflate
the group's qty or buying_amount. The selling amount and average
selling rate correctly reflect the rate adjustment.
"""
item = create_item("_Test Rate Adjustment Debit Note Item")
make_stock_entry(
company=self.company,
item_code=item.item_code,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = create_sales_invoice(
qty=1,
rate=200,
company=self.company,
customer=self.customer,
item_code=item.item_code,
item_name=item.item_code,
cost_center=self.cost_center,
warehouse=self.warehouse,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
update_stock=1,
currency="INR",
income_account=self.income_account,
expense_account=self.expense_account,
)
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Item Code",
)
columns, data = execute(filters=filters)
# group_by="Item Code" column order:
# [item_code, item_name, brand, description, qty, base_rate,
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
item_row = next((row for row in data if row[0] == item.item_code), None)
self.assertIsNotNone(item_row)
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
item_row[4],
item_row[5],
item_row[8],
item_row[7],
item_row[9],
item_row[10],
)
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
self.assertEqual(gross_profit, 120.0) # 220 - 100
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):

View File

@@ -84,8 +84,7 @@ def build_query_filters(filters: dict | None = None) -> list:
qb_filters = []
if filters:
if filters.account:
accounts = filters.account if isinstance(filters.account, list | tuple) else [filters.account]
qb_filters.append(qb.Field("account").isin(accounts))
qb_filters.append(qb.Field("account").isin(filters.account))
if filters.voucher_no:
qb_filters.append(qb.Field("voucher_no").eq(filters.voucher_no))

View File

@@ -1,152 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import qb
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.invalid_ledger_entries.invalid_ledger_entries import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestInvalidLedgerEntries(ERPNextTestSuite):
"""Tests for the Invalid Ledger Entries integrity report.
The report flags vouchers that still have *active* ledger entries
(GL Entry with is_cancelled=0 or Payment Ledger Entry with delinked=0)
in the given period, but whose source voucher document is no longer
submitted (docstatus != 1). Such orphaned ledgers indicate corruption.
"""
def setUp(self):
self.company = "_Test Company"
self.debit_account = "_Test Bank - _TC"
self.credit_account = "_Test Cash - _TC"
self.from_date = "2026-01-01"
self.to_date = "2026-12-31"
self.posting_date = "2026-06-01"
def run_report(self, **extra):
filters = frappe._dict(
{
"company": self.company,
"from_date": self.from_date,
"to_date": self.to_date,
}
)
filters.update(extra)
return execute(filters)[1]
def make_submitted_jv(self):
return make_journal_entry(
self.debit_account,
self.credit_account,
amount=500,
posting_date=self.posting_date,
company=self.company,
submit=True,
)
def test_healthy_voucher_not_flagged(self):
"""A normal balanced, submitted Journal Entry must NOT be flagged."""
jv = self.make_submitted_jv()
# It genuinely posted active GL entries, so it is in scope of the scan.
self.assertTrue(
frappe.db.exists(
"GL Entry",
{"voucher_no": jv.name, "is_cancelled": 0, "company": self.company},
)
)
flagged = {row.get("voucher_no") for row in self.run_report()}
self.assertNotIn(jv.name, flagged)
def test_orphaned_gl_entries_flagged(self):
"""A voucher whose document was set non-submitted while its GL entries
remain active (is_cancelled=0) must be flagged as invalid."""
jv = self.make_submitted_jv()
# Corrupt the state: mark the source document as cancelled (docstatus=2)
# without cancelling/removing its GL Entries. This is the exact orphaned
# ledger condition the report detects.
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
data = self.run_report()
matching = [
row
for row in data
if row.get("voucher_no") == jv.name and row.get("voucher_type") == "Journal Entry"
]
self.assertEqual(len(matching), 1, "Orphaned voucher should be flagged exactly once")
self.assertEqual(matching[0]["voucher_type"], "Journal Entry")
self.assertEqual(matching[0]["voucher_no"], jv.name)
def test_voucher_no_filter_scopes_scan(self):
"""The voucher_no filter must restrict the scan to that voucher only."""
orphan = self.make_submitted_jv()
other = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
frappe.db.set_value("Journal Entry", other.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report(voucher_no=orphan.name)}
self.assertIn(orphan.name, flagged)
self.assertNotIn(other.name, flagged)
def test_account_filter_scopes_scan(self):
"""The account filter (a MultiSelectList, so a list) must restrict the
scan to vouchers touching one of the given accounts."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
# Filtering on an account the voucher touches -> flagged.
flagged = {row.get("voucher_no") for row in self.run_report(account=[self.debit_account])}
self.assertIn(orphan.name, flagged)
# Filtering on an unrelated account -> not in scope.
unrelated = "Creditors - _TC"
flagged = {row.get("voucher_no") for row in self.run_report(account=[unrelated])}
self.assertNotIn(orphan.name, flagged)
def test_account_filter_accepts_a_scalar(self):
"""A scalar (non-list) account filter must not crash the query."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report(account=self.debit_account)}
self.assertIn(orphan.name, flagged)
def test_period_filter_excludes_out_of_range(self):
"""Vouchers posted outside the from/to window must not be scanned."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
flagged = {
row.get("voucher_no") for row in self.run_report(from_date="2025-01-01", to_date="2025-12-31")
}
self.assertNotIn(orphan.name, flagged)
def test_cancelled_gl_entries_not_flagged(self):
"""If the ledger entries are properly cancelled (is_cancelled=1), the
voucher is out of scope even when its document is non-submitted."""
jv = self.make_submitted_jv()
gle = qb.DocType("GL Entry")
qb.update(gle).set(gle.is_cancelled, 1).where(gle.voucher_no == jv.name).run()
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report()}
self.assertNotIn(jv.name, flagged)
def test_missing_filters_raises(self):
"""validate_filters must guard mandatory inputs."""
self.assertRaises(frappe.ValidationError, execute, None)
bad = frappe._dict({"from_date": self.from_date, "to_date": self.to_date})
self.assertRaises(frappe.ValidationError, execute, bad)
reversed_dates = frappe._dict(
{"company": self.company, "from_date": self.to_date, "to_date": self.from_date}
)
self.assertRaises(frappe.ValidationError, execute, reversed_dates)

View File

@@ -21,8 +21,6 @@ def execute(filters=None):
entries = get_entries(filters)
invoice_details = get_invoice_posting_date_map(filters)
report = ReceivablePayableReport(filters)
data = []
for d in entries:
invoice = invoice_details.get(d.against_voucher_no) or frappe._dict()
@@ -31,9 +29,7 @@ def execute(filters=None):
d.update({"range1": 0, "range2": 0, "range3": 0, "range4": 0, "outstanding": payment_amount})
if d.against_voucher_no:
# age the payment by how long after the invoice it was made (payment date - invoice date)
report.age_as_on = getdate(d.posting_date)
report.get_ageing_data(invoice.posting_date, d)
ReceivablePayableReport(filters).get_ageing_data(invoice.posting_date, d)
row = [
d.voucher_type,

View File

@@ -1,122 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import getdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.payment_period_based_on_invoice_date.payment_period_based_on_invoice_date import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
"""Depth tests for the Payment Period Based On Invoice Date report.
The report lists Payment Ledger Entries against invoices and buckets the paid
amount by the payment period -- how long after the invoice the payment was made
(payment date - invoice date) -- into ranges: range1 (0-30), range2 (30-60),
range3 (60-90), range4 (90 Above).
"""
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"payment_type": "Incoming",
"party_type": "Customer",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
return execute(filters)
def find_payment_row(self, data, payment_name):
# Row shape (positional): payment_document, payment_entry(voucher_no),
# party_type, party, posting_date, invoice(against_voucher_no),
# invoice_posting_date, due_date, amount, remarks, age,
# range1, range2, range3, range4, [delay_in_payment]
for row in data:
if row[1] == payment_name:
return row
return None
def pay_invoice(self, invoice, payment_date):
pe = get_payment_entry("Sales Invoice", invoice.name)
pe.posting_date = payment_date
pe.reference_no = "1"
pe.reference_date = payment_date
pe.submit()
return pe
def test_paid_amount_lands_in_0_30_bucket(self):
# invoice 2026-06-01, paid 2026-06-20 -> 19 days after -> 0-30 bucket
invoice = create_sales_invoice(customer="_Test Customer", rate=1000, posting_date="2026-06-01")
payment = self.pay_invoice(invoice, "2026-06-20")
columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
# Positional assertions on the row shape.
self.assertEqual(row[2], "Customer")
self.assertEqual(row[4], getdate("2026-06-20")) # payment posting date
self.assertEqual(row[5], invoice.name) # against invoice
self.assertEqual(row[6], getdate("2026-06-01")) # invoice posting date
self.assertEqual(row[8], 1000) # amount
self.assertEqual(row[10], 19) # age = payment date - invoice date
# Buckets: 0-30 filled, others empty.
self.assertEqual(row[11], 1000) # range1 (0-30)
self.assertEqual(row[12], 0) # range2 (30-60)
self.assertEqual(row[13], 0) # range3 (60-90)
self.assertEqual(row[14], 0) # range4 (90 Above)
def test_paid_amount_lands_in_30_60_bucket(self):
# invoice 2026-06-01, paid 2026-07-16 -> 45 days after -> 30-60 bucket
invoice = create_sales_invoice(customer="_Test Customer 1", rate=1000, posting_date="2026-06-01")
payment = self.pay_invoice(invoice, "2026-07-16")
columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row[8], 1000) # amount
self.assertEqual(row[10], 45) # age = payment date - invoice date
# Buckets: 30-60 filled, others empty.
self.assertEqual(row[11], 0) # range1 (0-30)
self.assertEqual(row[12], 1000) # range2 (30-60)
self.assertEqual(row[13], 0) # range3 (60-90)
self.assertEqual(row[14], 0) # range4 (90 Above)
def test_columns_expose_expected_age_buckets(self):
columns, _data = self.run_report()
labels_by_fieldname = {c["fieldname"]: c["label"] for c in columns}
self.assertEqual(labels_by_fieldname["range1"], "0-30")
self.assertEqual(labels_by_fieldname["range2"], "30-60")
self.assertEqual(labels_by_fieldname["range3"], "60-90")
self.assertEqual(labels_by_fieldname["range4"], "90 Above")
# Sales Invoice link for Incoming payments.
invoice_col = next(c for c in columns if c["fieldname"] == "invoice")
self.assertEqual(invoice_col["options"], "Sales Invoice")
def test_invalid_payment_type_party_type_combo_throws(self):
# Incoming + Supplier is invalid.
self.assertRaises(
frappe.ValidationError,
self.run_report,
payment_type="Incoming",
party_type="Supplier",
)
# Outgoing + Customer is invalid.
self.assertRaises(
frappe.ValidationError,
self.run_report,
payment_type="Outgoing",
party_type="Customer",
)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 2,
"is_standard": "Yes",
"modified": "2026-07-01 13:36:14.934965",
"modified": "2026-06-22 13:38:15.898375",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"synced_report": 0,
"timeout": 0
}

View File

@@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
return chart
def execute_snapshot_report(filters):
def execute_synced_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):

View File

@@ -1,105 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.profitability_analysis.profitability_analysis import execute
from erpnext.tests.utils import ERPNextTestSuite
INCOME = "Sales - _TC"
EXPENSE = "_Test Account Cost for Goods Sold - _TC"
BANK = "_Test Bank - _TC"
class TestProfitabilityAnalysis(ERPNextTestSuite):
def run_report(self, fiscal_year="_Test Fiscal Year 2026", **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"based_on": "Cost Center",
"fiscal_year": fiscal_year,
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)[1]
def make_cc(self, name, **args):
create_cost_center(cost_center_name=name, **args)
return name + " - _TC"
def row(self, data, account):
return next(r for r in data if r.get("account") == account)
def book_income(self, cost_center, amount, posting_date="2026-06-01"):
create_sales_invoice(
cost_center=cost_center, income_account=INCOME, rate=amount, qty=1, posting_date=posting_date
)
def book_expense(self, cost_center, amount, posting_date="2026-06-01"):
make_journal_entry(
EXPENSE, BANK, amount, cost_center=cost_center, posting_date=posting_date, submit=True
)
def test_income_expense_and_gross_profit(self):
# bootstrap leaf cost center; clean of committed GL so exact assertions hold
cc = "_Test Cost Center - _TC"
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
row = self.row(self.run_report(), cc)
self.assertEqual(row["income"], 10000)
self.assertEqual(row["expense"], 4000)
self.assertEqual(row["gross_profit_loss"], 6000)
def test_parent_cost_center_accumulates_children(self):
parent = self.make_cc("_Test PA Parent", is_group=1)
child_1 = self.make_cc("_Test PA Child 1", parent_cost_center=parent)
child_2 = self.make_cc("_Test PA Child 2", parent_cost_center=parent)
self.book_income(child_1, 10000)
self.book_expense(child_2, 3000)
data = self.run_report()
self.assertEqual(self.row(data, child_1)["income"], 10000)
self.assertEqual(self.row(data, child_2)["expense"], 3000)
parent_row = self.row(data, parent)
self.assertEqual(parent_row["income"], 10000)
self.assertEqual(parent_row["expense"], 3000)
self.assertEqual(parent_row["gross_profit_loss"], 7000)
def test_date_range_excludes_out_of_period_entries(self):
cc = "_Test Cost Center 2 - _TC"
self.book_income(cc, 10000, posting_date="2025-06-01")
# the 2025 income must not appear in a 2026 report (zero-value rows are dropped)
accounts_2026 = {r.get("account") for r in self.run_report()}
self.assertNotIn(cc, accounts_2026)
row_2025 = self.row(
self.run_report(
fiscal_year="_Test Fiscal Year 2025", from_date="2025-01-01", to_date="2025-12-31"
),
cc,
)
self.assertEqual(row_2025["income"], 10000)
def test_total_row_sums_income_and_expense(self):
cc = "_Test Cost Center - _TC"
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
data = self.run_report()
# the report appends a blank separator row and a totals row at the end
total_row = data[-1]
self.assertEqual(total_row["account"], "'Total'")
# total is built from direct (non-accumulated) values, so it stays internally consistent
self.assertEqual(total_row["gross_profit_loss"], total_row["income"] - total_row["expense"])
# and it includes this test's bookings
self.assertGreaterEqual(total_row["income"], 10000)
self.assertGreaterEqual(total_row["expense"], 4000)

View File

@@ -1,171 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.purchase_invoice_trends.purchase_invoice_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
COMPANY = "_Test Company"
SUPPLIER = "_Test Supplier"
ITEM = "_Test Item"
POSTING_DATE = "2026-06-01"
def make_dated_purchase_invoice(qty, rate):
# make_purchase_invoice ignores posting_date unless posting time is explicitly set, so build the
# invoice unsubmitted, pin the posting date, then submit to land it in the intended period bucket.
pi = make_purchase_invoice(
supplier=SUPPLIER, item_code=ITEM, qty=qty, rate=rate, posting_date=POSTING_DATE, do_not_submit=1
)
pi.set_posting_time = 1
pi.posting_date = POSTING_DATE
pi.submit()
return pi
class TestPurchaseInvoiceTrends(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": COMPANY,
"fiscal_year": FISCAL_YEAR,
"period": "Yearly",
"based_on": "Item",
}
)
filters.update(extra)
columns, data = execute(filters)
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
@staticmethod
def _cell(labels, row, label):
return row[labels.index(label)]
def _find_row(self, data, key):
for row in data:
if row and row[0] == key:
return row
return None
def test_yearly_item_qty_and_amount(self):
labels_before, data_before = self.run_report()
before = self._find_row(data_before, ITEM)
qty, rate = 4, 250
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report()
self.assertIn("Item", labels)
self.assertIn("Item Name", labels)
self.assertIn("Currency", labels)
self.assertIn("Total(Qty)", labels)
self.assertIn("Total(Amt)", labels)
# Yearly period bucket uses the fiscal year name as the label prefix
self.assertIn(f"{FISCAL_YEAR} (Qty)", labels)
self.assertIn(f"{FISCAL_YEAR} (Amt)", labels)
row = self._find_row(data, ITEM)
self.assertIsNotNone(row)
before_qty = self._cell(labels_before, before, f"{FISCAL_YEAR} (Qty)") if before else 0
before_amt = self._cell(labels_before, before, f"{FISCAL_YEAR} (Amt)") if before else 0
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Amt)") - before_amt, qty * rate)
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_monthly_bucket(self):
labels_before, data_before = self.run_report(period="Monthly")
before = self._find_row(data_before, ITEM)
qty, rate = 3, 100
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(period="Monthly")
# posting_date 2026-06-01 -> June bucket
self.assertIn("Jun (Qty)", labels)
self.assertIn("Jun (Amt)", labels)
row = self._find_row(data, ITEM)
before_qty = self._cell(labels_before, before, "Jun (Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Jun (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_quarterly_bucket(self):
labels_before, data_before = self.run_report(period="Quarterly")
before = self._find_row(data_before, ITEM)
qty, rate = 2, 150
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(period="Quarterly")
# 2026-06-01 falls in the Apr-Jun quarter
self.assertIn("Apr-Jun (Qty)", labels)
self.assertIn("Apr-Jun (Amt)", labels)
row = self._find_row(data, ITEM)
before_qty = self._cell(labels_before, before, "Apr-Jun (Qty)") if before else 0
before_amt = self._cell(labels_before, before, "Apr-Jun (Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Apr-Jun (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, "Apr-Jun (Amt)") - before_amt, qty * rate)
def test_based_on_supplier(self):
labels_before, data_before = self.run_report(based_on="Supplier")
before = self._find_row(data_before, SUPPLIER)
qty, rate = 5, 200
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(based_on="Supplier")
self.assertIn("Supplier", labels)
self.assertIn("Supplier Name", labels)
self.assertIn("Supplier Group", labels)
row = self._find_row(data, SUPPLIER)
self.assertIsNotNone(row)
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_group_by_item_under_supplier(self):
labels_before, data_before = self.run_report(based_on="Supplier", group_by="Item")
# group_by inserts an "Item" column; the item breakdown row carries the item key there
item_idx = labels_before.index("Item")
before = None
for r in data_before:
if r and r[0] != SUPPLIER and r[item_idx] == ITEM:
before = r
break
qty, rate = 6, 300
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(based_on="Supplier", group_by="Item")
self.assertIn("Item", labels)
item_idx = labels.index("Item")
row = None
for r in data:
if r and r[0] != SUPPLIER and r[0] != "'Total'" and r[item_idx] == ITEM:
row = r
break
self.assertIsNotNone(row)
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)

View File

@@ -1,101 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.received_items_to_be_billed.received_items_to_be_billed import execute
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice as make_pi_from_pr
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
class TestReceivedItemsToBeBilled(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": "2026-06-30",
}
)
filters.update(extra)
return execute(filters)[1]
def get_row(self, data, purchase_receipt):
matches = [row for row in data if row.get("name") == purchase_receipt]
return matches[0] if matches else None
def test_unbilled_receipt_appears_with_pending_amount(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
row = self.get_row(self.run_report(), pr.name)
self.assertIsNotNone(row, "Unbilled Purchase Receipt should appear in the report")
self.assertEqual(row.get("supplier"), "_Test Supplier")
self.assertEqual(row.get("item_code"), "_Test Item")
self.assertEqual(row.get("amount"), 1000.0)
self.assertEqual(row.get("billed_amount"), 0.0)
self.assertEqual(row.get("returned_amount"), 0.0)
self.assertEqual(row.get("pending_amount"), 1000.0)
def test_billed_receipt_drops_out_of_report(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
self.assertIsNotNone(self.get_row(self.run_report(), pr.name))
pi = make_pi_from_pr(pr.name)
pi.set_posting_time = 1
pi.posting_date = "2026-06-02"
pi.submit()
self.assertIsNone(
self.get_row(self.run_report(), pr.name),
"Fully billed Purchase Receipt should no longer appear in the report",
)
def test_reference_field_filter_limits_to_single_receipt(self):
first_pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
second_pr = make_purchase_receipt(
item_code="_Test Item",
qty=3,
rate=100,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
data = self.run_report(purchase_receipt=first_pr.name)
self.assertIsNotNone(self.get_row(data, first_pr.name))
self.assertIsNone(self.get_row(data, second_pr.name))
def test_posting_date_cutoff_excludes_later_receipts(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-15",
)
self.assertIsNone(
self.get_row(self.run_report(posting_date="2026-06-01"), pr.name),
"Receipt dated after the cutoff should be excluded",
)
self.assertIsNotNone(self.get_row(self.run_report(posting_date="2026-06-30"), pr.name))

View File

@@ -1,118 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_invoice_trends.sales_invoice_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
POSTING_DATE = "2026-06-01"
class TestSalesInvoiceTrends(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": FISCAL_YEAR,
"based_on": "Item",
"period": "Yearly",
}
)
filters.update(extra)
columns, data = execute(filters)
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
def _cell(self, data, key_label, key_value, col_label, labels):
"""Return the value at column `col_label` for the row whose first-column
value equals `key_value`, or 0 if that row does not exist yet."""
key_idx = labels.index(key_label)
col_idx = labels.index(col_label)
for row in data:
if row[key_idx] == key_value:
return row[col_idx] or 0
return 0
def test_yearly_item_amount_and_total(self):
# Yearly period => a single "<FY> (Qty)"/"(Amt)" bucket, plus Total(Qty)/Total(Amt).
labels, before = self.run_report()
qty_col = f"{FISCAL_YEAR} (Qty)"
amt_col = f"{FISCAL_YEAR} (Amt)"
before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels)
before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels)
before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(item="_Test Item", qty=4, rate=200, posting_date=POSTING_DATE)
labels, after = self.run_report()
self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800)
def test_monthly_lands_in_june_bucket(self):
# Monthly period => one bucket per month; a 2026-06-01 invoice hits "Jun (Qty)"/"(Amt)".
labels, before = self.run_report(period="Monthly")
before_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
before_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
before_tot = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(item="_Test Item", qty=3, rate=100, posting_date=POSTING_DATE)
labels, after = self.run_report(period="Monthly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_qty, 3)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_amt, 300)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot, 300)
# Nothing should leak into an unrelated month.
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan (Amt)", labels), 0)
def test_quarterly_lands_in_apr_jun_bucket(self):
# Quarterly period over a Jan-Dec fiscal year => Apr-Jun is the 2nd quarter; June lands there.
labels, before = self.run_report(period="Quarterly")
before_qty = self._cell(before, "Item", "_Test Item", "Apr-Jun (Qty)", labels)
before_amt = self._cell(before, "Item", "_Test Item", "Apr-Jun (Amt)", labels)
create_sales_invoice(item="_Test Item", qty=5, rate=50, posting_date=POSTING_DATE)
labels, after = self.run_report(period="Quarterly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Qty)", labels) - before_qty, 5)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Amt)", labels) - before_amt, 250)
# Jan-Mar quarter must stay untouched.
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan-Mar (Amt)", labels), 0)
def test_based_on_customer_total(self):
# based_on=Customer => first column is "Customer"; the customer's Total(Amt) reflects the sale.
labels, before = self.run_report(based_on="Customer")
before_tot_qty = self._cell(before, "Customer", "_Test Customer", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Customer", "_Test Customer", "Total(Amt)", labels)
create_sales_invoice(
customer="_Test Customer", item="_Test Item", qty=2, rate=300, posting_date=POSTING_DATE
)
labels, after = self.run_report(based_on="Customer")
self.assertEqual(
self._cell(after, "Customer", "_Test Customer", "Total(Qty)", labels) - before_tot_qty, 2
)
self.assertEqual(
self._cell(after, "Customer", "_Test Customer", "Total(Amt)", labels) - before_tot_amt, 600
)
def test_group_by_item_under_customer(self):
# based_on=Customer + group_by=Item inserts an "Item" breakdown column before the period
# buckets; the per-item detail row carries the item key and the amount for that customer/item.
labels, before = self.run_report(based_on="Customer", group_by="Item")
# In group_by mode the detail rows key off the group_by column ("Item"), so snapshot by item.
before_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(
customer="_Test Customer", item="_Test Item", qty=6, rate=100, posting_date=POSTING_DATE
)
labels, after = self.run_report(based_on="Customer", group_by="Item")
self.assertIn("Item", labels)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_amt, 600)

View File

@@ -15,6 +15,8 @@ def execute(filters=None):
columns = get_columns(filters)
filters.get("date")
data = []
if not filters.get("shareholder"):
@@ -22,7 +24,7 @@ def execute(filters=None):
else:
share_type, no_of_shares, rate, amount = 1, 2, 3, 4
all_shares = get_all_shares(filters.get("shareholder"), filters.get("date"))
all_shares = get_all_shares(filters.get("shareholder"))
for share_entry in all_shares:
row = False
for datum in data:
@@ -61,28 +63,5 @@ def get_columns(filters):
return columns
def get_all_shares(shareholder, date):
"""Share movements for the shareholder up to (and including) `date`, signed by direction:
shares received are positive, shares transferred/sold out are negative."""
transfers = frappe.get_all(
"Share Transfer",
filters={"docstatus": 1, "date": ("<=", date)},
fields=["share_type", "no_of_shares", "rate", "amount", "from_shareholder", "to_shareholder"],
order_by="date",
)
shares = []
for transfer in transfers:
if transfer.to_shareholder == shareholder:
shares.append(transfer)
elif transfer.from_shareholder == shareholder:
shares.append(
frappe._dict(
share_type=transfer.share_type,
no_of_shares=-transfer.no_of_shares,
rate=transfer.rate,
amount=-transfer.amount,
)
)
return shares
def get_all_shares(shareholder):
return frappe.get_doc("Shareholder", shareholder).share_balance

View File

@@ -1,177 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.share_balance.share_balance import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestShareBalanceReport(ERPNextTestSuite):
def setUp(self):
self.share_type = create_share_type("_Test Share Balance Equity")
self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY)
def test_date_filter_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder}))
def test_no_shareholder_returns_empty_data(self):
# `shareholder` is optional; without it the report yields no rows.
columns, data = execute(frappe._dict({"date": "2026-06-01", "company": COMPANY}))
self.assertEqual(data, [])
self.assertEqual(len(columns), 5)
def test_balance_after_issue(self):
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
row = self.get_row(date="2026-06-05")
self.assertEqual(row[0], self.shareholder)
self.assertEqual(row[1], self.share_type)
self.assertEqual(row[2], 100) # no_of_shares
self.assertEqual(row[3], 10) # average rate
self.assertEqual(row[4], 1000) # amount = 100 * 10
def test_balance_increases_on_second_issue(self):
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=101,
to_no=200,
no_of_shares=100,
rate=20,
date="2026-06-10",
)
# The report groups by share type, summing shares and amount and
# recomputing the average rate: (1000 + 2000) / 200 = 15.
row = self.get_row(date="2026-06-15")
self.assertEqual(row[2], 200)
self.assertEqual(row[3], 15)
self.assertEqual(row[4], 3000)
def test_balance_reduces_after_transfer_out(self):
other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Transfer",
from_shareholder=self.shareholder,
to_shareholder=other_holder,
share_type=self.share_type,
from_no=1,
to_no=40,
no_of_shares=40,
rate=10,
date="2026-06-10",
)
row = self.get_row(date="2026-06-15")
self.assertEqual(row[2], 60) # 100 issued - 40 transferred out
self.assertEqual(row[4], 600)
other_row = self.get_row(date="2026-06-15", shareholder=other_holder)
self.assertEqual(other_row[2], 40)
self.assertEqual(other_row[4], 400)
def test_as_on_date_before_issue_shows_no_holding(self):
# the report is as-on `date`: before any share transfer, the shareholder holds nothing
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
data = execute(
frappe._dict({"date": "2026-05-01", "company": COMPANY, "shareholder": self.shareholder})
)[1]
self.assertEqual(data, [])
def test_as_on_date_reflects_holding_up_to_that_date(self):
# two issues on different dates; an as-on date between them sees only the first
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=101,
to_no=200,
no_of_shares=100,
rate=20,
date="2026-06-10",
)
self.assertEqual(self.get_row(date="2026-06-05")[2], 100) # only the first issue
self.assertEqual(self.get_row(date="2026-06-15")[2], 200) # both issues
def get_row(self, date, shareholder=None):
filters = frappe._dict(
{"date": date, "company": COMPANY, "shareholder": shareholder or self.shareholder}
)
data = execute(filters)[1]
holdings = [r for r in data if r[1] == self.share_type]
self.assertEqual(len(holdings), 1, f"Expected one row for share type, got: {data}")
return holdings[0]
def create_share_type(title):
if not frappe.db.exists("Share Type", title):
frappe.get_doc({"doctype": "Share Type", "title": title}).insert()
return title
def create_shareholder(title, company):
shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert()
return shareholder.name
def create_share_transfer(**kwargs):
kwargs.setdefault("company", COMPANY)
kwargs.setdefault("asset_account", "Cash - _TC")
kwargs.setdefault("equity_or_liability_account", "Creditors - _TC")
transfer = frappe.get_doc({"doctype": "Share Transfer", **kwargs})
transfer.submit()
return transfer

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-07-01 17:32:21.801141",
"modified": "2026-06-22 13:38:42.740436",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"synced_report": 0,
"timeout": 0
}

View File

@@ -583,7 +583,7 @@ def hide_group_accounts(data):
return non_group_accounts_data
def execute_snapshot_report(filters):
def execute_synced_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):

View File

@@ -1,63 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.voucher_wise_balance.voucher_wise_balance import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestVoucherWiseBalance(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, voucher_no):
for row in data:
if row.get("voucher_no") == voucher_no:
return row
return None
def test_balanced_voucher_not_flagged(self):
jv = make_journal_entry(
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
)
data = self.run_report()
self.assertIsNone(
self.find_row(data, jv.name),
msg="A balanced voucher (debit == credit) must not be flagged.",
)
def test_imbalanced_voucher_flagged(self):
jv = make_journal_entry(
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
)
# Tamper one GL Entry: drop the debit side so debit != credit for this voucher.
gle_name = frappe.db.get_value(
"GL Entry",
{"voucher_no": jv.name, "is_cancelled": 0, "debit": [">", 0]},
"name",
)
self.assertIsNotNone(gle_name, msg="Expected a debit GL Entry for the journal entry.")
frappe.db.set_value("GL Entry", gle_name, {"debit": 400, "debit_in_account_currency": 400})
data = self.run_report()
row = self.find_row(data, jv.name)
self.assertIsNotNone(row, msg="An imbalanced voucher must be flagged by the report.")
self.assertEqual(row.get("voucher_type"), "Journal Entry")
self.assertEqual(row.get("credit"), 1000)
self.assertEqual(row.get("debit"), 400)
self.assertNotEqual(
row.get("debit"), row.get("credit"), msg="Flagged rows must have debit != credit."
)

View File

@@ -12,6 +12,7 @@ from frappe.utils import cint, flt, parse_json
import erpnext
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
ItemDetailsCtx,
_get_item_tax_template,
_get_item_tax_template_from_item_group,
get_item_tax_map,
@@ -349,7 +350,7 @@ def set_balance_in_account_currency(
def set_child_tax_template_and_map(item, child_item, parent_doc) -> None:
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"item_code": item.item_code,
"posting_date": parent_doc.transaction_date,

View File

@@ -500,7 +500,7 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
item_group_defaults = get_item_group_defaults(item.name, company)
brand_defaults = get_brand_defaults(item.name, company)
out.cost_center = get_default_cost_center(
frappe._dict({"item_code": item.name, "company": company}),
ItemDetailsCtx({"item_code": item.name, "company": company}),
item_defaults,
item_group_defaults,
brand_defaults,

View File

@@ -1531,11 +1531,11 @@ class TestPurchaseOrder(ERPNextTestSuite):
(via the standard item lookup the form uses) without going through
the Sales Order → Purchase Order mapping pipeline.
"""
from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
item = make_item("_Test Drop Ship From Master", {"is_stock_item": 1, "delivered_by_supplier": 1})
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"item_code": item.item_code,
"doctype": "Purchase Order",

View File

@@ -55,16 +55,9 @@ class SupplierScorecard(Document):
self.update_standing()
def on_update(self):
# Guard against recursion: the save() below re-enters on_update().
if self.flags.in_rescore:
return
if make_all_scorecards(self.name) > 0:
# New periods were created; re-save to refresh score and standings.
self.flags.in_rescore = True
try:
self.save()
finally:
self.flags.in_rescore = False
score = make_all_scorecards(self.name)
if score > 0:
self.save()
def validate_standings(self):
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps

View File

@@ -1,130 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
)
from erpnext.buying.report.item_wise_purchase_history.item_wise_purchase_history import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestItemWisePurchaseHistory(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)
def po_row(self, po_name, **extra):
data = self.run_report(**extra)[1]
return next(row for row in data if row["purchase_order"] == po_name)
def test_purchase_order_line_shown_with_values(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
row = self.po_row(po.name)
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["quantity"], 10)
self.assertEqual(row["rate"], 500)
self.assertEqual(row["amount"], 5000)
self.assertEqual(row["supplier"], "_Test Supplier")
def test_draft_purchase_order_excluded(self):
po = create_purchase_order(transaction_date="2026-06-01", do_not_submit=True)
names = {row["purchase_order"] for row in self.run_report()[1]}
self.assertNotIn(po.name, names)
def test_date_range_filters_on_transaction_date(self):
po = create_purchase_order(transaction_date="2026-06-01")
in_range = {
row["purchase_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
}
self.assertIn(po.name, in_range)
out_of_range = {
row["purchase_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
}
self.assertNotIn(po.name, out_of_range)
def test_item_code_filter(self):
po = create_purchase_order(
transaction_date="2026-06-01",
rm_items=[
{"item_code": "_Test Item", "qty": 5, "rate": 500, "warehouse": "_Test Warehouse - _TC"},
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
],
)
rows = self.run_report(item_code="_Test Item 2")[1]
self.assertEqual({row["item_code"] for row in rows}, {"_Test Item 2"})
# the filtered-out line of the same order must not leak in
self.assertTrue(all(row["purchase_order"] == po.name for row in rows))
def test_item_group_filter(self):
# _Test Item is in _Test Item Group; _Test FG Item is in _Test Item Group Desktops
po_test_group = create_purchase_order(item_code="_Test Item", transaction_date="2026-06-01")
po_other_group = create_purchase_order(item_code="_Test FG Item", transaction_date="2026-06-01")
names = {row["purchase_order"] for row in self.run_report(item_group="_Test Item Group")[1]}
self.assertIn(po_test_group.name, names)
self.assertNotIn(po_other_group.name, names)
def test_supplier_filter(self):
create_purchase_order(supplier="_Test Supplier", transaction_date="2026-06-01")
create_purchase_order(supplier="_Test Supplier 1", transaction_date="2026-06-01")
suppliers = {row["supplier"] for row in self.run_report(supplier="_Test Supplier")[1]}
self.assertEqual(suppliers, {"_Test Supplier"})
def test_received_quantity_reflects_receipt(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
create_pr_against_po(po.name, received_qty=4)
self.assertEqual(self.po_row(po.name)["received_qty"], 4)
def test_billed_amount_reflects_invoice(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
pi = make_purchase_invoice(po.name)
pi.insert()
pi.submit()
self.assertEqual(self.po_row(po.name)["billed_amt"], 5000)
def test_amounts_reported_in_company_currency(self):
# a USD order must report rate/amount converted to the company's currency (base_* fields)
po = create_purchase_order(
do_not_save=True,
currency="USD",
qty=10,
rate=100,
transaction_date="2026-06-01",
)
po.conversion_rate = 80
po.insert()
po.submit()
row = self.po_row(po.name)
self.assertEqual(row["rate"], 8000) # 100 USD * 80
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
def test_chart_aggregates_amount_per_item(self):
create_purchase_order(item_code="_Test Item", qty=2, rate=500, transaction_date="2026-06-01")
create_purchase_order(item_code="_Test Item", qty=3, rate=500, transaction_date="2026-06-01")
chart = self.run_report(item_code="_Test Item")[3]
labels = chart["data"]["labels"]
values = chart["data"]["datasets"][0]["values"]
self.assertIn("_Test Item", labels)
# 2*500 + 3*500 aggregated for the item
self.assertEqual(values[labels.index("_Test Item")], 2500)

View File

@@ -47,6 +47,7 @@ from erpnext.controllers.sales_and_purchase_return import validate_return
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_uom_conv_factor
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_item_details,
)
from erpnext.utilities.regional import temporary_flag
@@ -781,7 +782,7 @@ class AccountsController(TransactionBase):
for item in self.get("items"):
if item.get("item_code"):
ctx: frappe._dict = frappe._dict(parent_dict.copy())
ctx: ItemDetailsCtx = ItemDetailsCtx(parent_dict.copy())
ctx.update(item.as_dict())
ctx.update(

View File

@@ -25,7 +25,7 @@ from pypika import Order
import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.stock.get_item_details import _get_item_tax_template
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
from erpnext.utilities.query import get_filter_conditions_qb
@@ -1056,7 +1056,7 @@ def get_tax_template(doctype: str, txt: str, searchfield: str, start: int, page_
valid_from = filters.get("valid_from")
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"item_code": filters.get("item_code"),
"posting_date": valid_from,

View File

@@ -167,8 +167,7 @@ status_map = {
"Pick List": [
["Draft", None],
["Open", "eval:self.docstatus == 1"],
["Completed", "is_fully_transferred"],
["Partially Transferred", "is_partially_transferred"],
["Completed", "stock_entry_exists"],
[
"Partly Delivered",
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",

View File

@@ -21,6 +21,7 @@ from erpnext.controllers.accounts_controller import (
from erpnext.deprecation_dumpster import deprecated
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
ItemDetailsCtx,
_get_item_tax_template,
get_item_tax_map,
)
@@ -98,7 +99,7 @@ class calculate_taxes_and_totals:
for item in self.doc.items:
if item.item_code and item.get("item_tax_template"):
item_doc = frappe.get_cached_doc("Item", item.item_code)
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"net_rate": item.net_rate or item.rate,
"base_net_rate": item.base_net_rate or item.base_rate,

View File

@@ -154,26 +154,15 @@ def create_customer(customer_data: dict | None = None):
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
customer_name = customer.name
except Exception:
frappe.db.rollback()
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
return
# Link contacts/address under a savepoint so a failure here does NOT discard the Customer just
# created (a full rollback would; MariaDB kept it pre-migration). Linking is best-effort.
frappe.db.savepoint("crm_customer_links")
try:
contacts = frappe.parse_json(customer_data.get("contacts"))
create_contacts(contacts, customer_name, "Customer", customer_name)
create_address("Customer", customer_name, customer_data.get("address"))
return customer_name
except Exception:
frappe.db.rollback(save_point="crm_customer_links")
frappe.log_error(frappe.get_traceback(), "Error while linking contacts/address to new Customer")
# keep the Customer, but preserve the pre-existing contract of returning None on a linking failure
# so CRM callers still see the failure signal
return
return customer_name
frappe.db.rollback()
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
pass
def validate_frappe_crm_sync():

View File

@@ -215,14 +215,7 @@ def sync_transactions(bank, bank_account):
result = []
if transactions:
for transaction in reversed(transactions):
# per-transaction savepoint: a failed insert/submit must not discard the Bank
# Transactions already synced this run (MariaDB keeps them) nor poison the txn on Postgres
frappe.db.savepoint("plaid_sync_txn")
try:
result += new_bank_transaction(transaction)
except Exception:
frappe.db.rollback(save_point="plaid_sync_txn")
raise
result += new_bank_transaction(transaction)
if result:
last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date")

View File

@@ -507,7 +507,6 @@ scheduler_events = {
],
"weekly": [
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
"erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries",
],
"monthly_long": [
"erpnext.accounts.deferred_revenue.process_deferred_accounting",

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
"PO-Revision-Date: 2026-06-29 20:08\n"
"PO-Revision-Date: 2026-07-01 20:39\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@@ -14218,7 +14218,7 @@ msgstr "آدرس فعلی"
#. Label of the current_accommodation_type (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Current Address Is"
msgstr "آدرس فعلی است"
msgstr "آدرس فعلی"
#. Label of the current_amount (Currency) field in DocType 'Stock
#. Reconciliation Item'
@@ -17767,7 +17767,7 @@ msgstr "سود سهام پرداخت شده"
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Divorced"
msgstr "جدا شده"
msgstr "طلاق گرفته"
#. Option for the 'Status' (Select) field in DocType 'Lead'
#: erpnext/crm/doctype/lead/lead.json
@@ -30241,7 +30241,7 @@ msgstr "متخصص بازاریابی"
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Married"
msgstr "متاهل"
msgstr "متأهل"
#: erpnext/setup/setup_wizard/data/marketing_source.txt:7
msgid "Mass Mailing"
@@ -34688,7 +34688,7 @@ msgstr ""
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Owned"
msgstr "مالکیت"
msgstr "ملکی"
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.js:29
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py:24
@@ -37162,7 +37162,7 @@ msgstr "آدرس دائمی"
#. 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Permanent Address Is"
msgstr "آدرس دائمی است"
msgstr "آدرس دائمی"
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:73
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:77
@@ -44208,7 +44208,7 @@ msgstr "اجاره"
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Rented"
msgstr "اجاره شده"
msgstr "استیجاری"
#. Label of the reorder_level (Float) field in DocType 'Material Request Item'
#: erpnext/stock/doctype/material_request_item/material_request_item.json
@@ -50940,7 +50940,7 @@ msgstr ""
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Single"
msgstr "تنها"
msgstr "مجرد"
#. Option for the 'Bank Entry Type' (Select) field in DocType 'Bank Transaction
#. Rule'
@@ -60982,7 +60982,7 @@ msgstr "هنگام تهیه فاکتور خرید از سفارش خرید، ب
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Widowed"
msgstr "بیوه"
msgstr "همسر فوت شده"
#. Label of the width (Float) field in DocType 'Shipment Parcel'
#. Label of the width (Float) field in DocType 'Shipment Parcel Template'
@@ -61884,7 +61884,7 @@ msgstr "تراز صفر"
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:353
msgid "Zero Balance Journal: {0}"
msgstr ""
msgstr "دفتر تراز صفر: {0}"
#: erpnext/regional/report/uae_vat_201/uae_vat_201.py:78
msgid "Zero Rated"
@@ -62008,7 +62008,7 @@ msgstr "fieldname"
#: erpnext/setup/doctype/item_group/item_group.py:49
msgid "for tax category {0}"
msgstr ""
msgstr "برای دسته بندی مالیاتی {0}"
#. Option for the 'Service Provider' (Select) field in DocType 'Currency
#. Exchange Settings'
@@ -62375,7 +62375,7 @@ msgstr ""
#: erpnext/public/js/utils/sales_common.js:336
msgid "{0} cannot be greater than 100"
msgstr ""
msgstr "{0} نمی‌تواند بزرگتر از ۱۰۰ باشد"
#: erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py:136
msgid "{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}"
@@ -62444,7 +62444,7 @@ msgstr "{0} با موفقیت ارسال شد"
#: erpnext/controllers/buying_controller.py:289
msgid "{0} has submitted assets linked to it. You need to cancel the assets to create purchase return."
msgstr ""
msgstr "{0} دارایی‌های مرتبط با آن را ارسال کرده است. برای ایجاد بازگشت خرید، باید دارایی‌ها را لغو کنید."
#: erpnext/projects/doctype/project/project_dashboard.html:15
msgid "{0} hours"
@@ -62456,7 +62456,7 @@ msgstr "{0} در ردیف {1}"
#: erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py:66
msgid "{0} is a child company."
msgstr ""
msgstr "{0} یک شرکت فرزند است."
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:465
msgid "{0} is a child table and will be deleted automatically with its parent"
@@ -62539,7 +62539,7 @@ msgstr "{0} در {1} فعال نیست"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:649
msgid "{0} is not running. Cannot trigger events for this document"
msgstr ""
msgstr "{0} در حال اجرا نیست. نمی‌توان رویدادها را برای این سند فعال کرد"
#: erpnext/stock/doctype/material_request/material_request.py:478
msgid "{0} is not the default supplier for any items."
@@ -62547,7 +62547,7 @@ msgstr "{0} تامین کننده پیش‌فرض هیچ موردی نیست."
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:2686
msgid "{0} is on hold until {1}"
msgstr ""
msgstr "{0} تا زمان {1} در حالت انتظار است"
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:68
msgid "{0} is open. Close the POS or cancel the existing POS Opening Entry to create a new POS Opening Entry."
@@ -62722,11 +62722,11 @@ msgstr ""
#: erpnext/accounts/doctype/party_link/party_link.py:53
#: erpnext/accounts/doctype/party_link/party_link.py:63
msgid "{0} {1} is already linked with another {2}"
msgstr ""
msgstr "{0} {1} از قبل به {2} دیگری لینک شده است"
#: erpnext/accounts/doctype/party_link/party_link.py:40
msgid "{0} {1} is already linked with {2} {3}"
msgstr ""
msgstr "{0} {1} از قبل به {2} {3} لینک شده است"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:711
msgid "{0} {1} is associated with {2}, but Party Account is {3}"
@@ -62767,7 +62767,7 @@ msgstr "{0} {1} فعال نیست"
#: erpnext/accounts/doctype/bank_transaction/bank_transaction.py:452
msgid "{0} {1} is not affecting bank account {2}"
msgstr ""
msgstr "{0} {1} تاثیری بر حساب بانکی {2} ندارد"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:688
msgid "{0} {1} is not associated with {2} {3}"

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
"PO-Revision-Date: 2026-06-29 20:08\n"
"PO-Revision-Date: 2026-07-01 20:39\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -8227,7 +8227,7 @@ msgstr "Saldo Historik per Parti"
#: erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py:183
#: erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py:86
msgid "Batchwise Valuation"
msgstr "Partivis Värdering"
msgstr "Partibaserad Värdering"
#. Label of the section_break_3 (Section Break) field in DocType 'Stock
#. Reconciliation Item'
@@ -9319,7 +9319,7 @@ msgstr "Beräkna Uppskatade Ankomst Tider"
#. Settings'
#: erpnext/selling/doctype/selling_settings/selling_settings.json
msgid "Calculate Product Bundle price based on child Item's rates"
msgstr "Beräkna Artikel Paket pris baserat på priser för underordnade artiklar"
msgstr "Beräkna Artikel Paket pris baserat på priser för paket artiklar"
#. Description of the 'Hidden Line (Internal Use Only)' (Check) field in
#. DocType 'Financial Report Row'
@@ -15680,7 +15680,7 @@ msgstr "Avdraget från"
#. Deduction Certificate'
#: erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json
msgid "Deductee Details"
msgstr "Avdragsberättigad Detaljer"
msgstr "Avdragstagare Detaljer"
#. Label of a Workspace Sidebar Item
#: erpnext/workspace_sidebar/taxes.json
@@ -17908,7 +17908,7 @@ msgstr "Uppdatera inte Varianter vid Spara"
#. Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Do not use Batch-wise Valuation"
msgstr "Använd inte Partivis Värdering"
msgstr "Använd inte Partibaserad Värdering"
#: erpnext/assets/doctype/asset/asset.js:957
msgid "Do you really want to restore this scrapped asset?"
@@ -19193,9 +19193,9 @@ msgid "Enabling this will do the following:\n"
msgstr "Om du aktiverar detta kommer följande att hända:\n"
"<ul style=\"padding-left:16px\">\n"
"<li>Pris Kolumn i alla Artikel Paket tabeller redigerbar.</li>\n"
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">artikel paket</a> i Artikel tabell, baserat på priser för dess underordnade artiklar, som anges i Artikel Paket tabell. </li>\n"
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">Artikel Paket</a> i Artikel tabell, baserat på priser för paket artiklar, som anges i Artikel Paket tabell. </li>\n"
"</ul>\n"
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra dess pris. Det kommer att återställas till det pris som baseras på dess underordnade artiklar när dokumentet sparas."
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra deras pris. Det kommer att återställas till pris som baseras på paket artiklar när dokument sparas."
#. Label of the encashment_date (Date) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -33834,7 +33834,7 @@ msgstr "Öppning Faktura Verktyg"
#: erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py:832
#: erpnext/accounts/doctype/sales_invoice/services/gl_composer.py:642
msgid "Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
msgstr "Öppning Fakturan har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
msgstr "Öppning Faktura har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
#: erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html:8
msgid "Opening Invoices"
@@ -59194,7 +59194,7 @@ msgstr "Använd <strong>Python</strong> filter för att hämta Konton"
#. Label of the use_batchwise_valuation (Check) field in DocType 'Batch'
#: erpnext/stock/doctype/batch/batch.json
msgid "Use Batch-wise Valuation"
msgstr "Använd Partivis Värdering"
msgstr "Använd Partibaserad Värdering"
#. Label of the use_csv_sniffer (Check) field in DocType 'Bank Statement
#. Import'
@@ -60825,7 +60825,7 @@ msgstr "Garanti Utgång (Serienummer)"
#: erpnext/stock/doctype/serial_no/serial_no.json
#: erpnext/support/doctype/warranty_claim/warranty_claim.json
msgid "Warranty Expiry Date"
msgstr "Garanti Utgångsdatum"
msgstr "Garanti Utgång Datum"
#. Label of the warranty_period (Int) field in DocType 'Serial No'
#: erpnext/stock/doctype/serial_no/serial_no.json

View File

@@ -16,7 +16,7 @@ from frappe.website.website_generator import WebsiteGenerator
import erpnext
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_item_details
from erpnext.stock.get_item_details import get_conversion_factor, get_price_list_rate
from erpnext.stock.get_item_details import ItemDetailsCtx, get_conversion_factor, get_price_list_rate
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -739,7 +739,7 @@ class BOM(WebsiteGenerator):
)
)
def check_recursion(self):
def check_recursion(self, bom_list=None):
"""Check whether recursion occurs in any bom"""
bom_list = self.traverse_tree()
child_items = frappe.get_all(
@@ -861,30 +861,21 @@ class BOM(WebsiteGenerator):
self.append("items", row)
def traverse_tree(self):
"""Return this BOM and every descendant BOM. The whole sub-tree is fetched in one recursive
CTE (frappe.qb) instead of a query-per-node walk; the only caller (check_recursion) uses the
result purely as a membership set. Portable across postgres and mariadb 10.2+."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("bom_tree")
def traverse_tree(self, bom_list=None):
count = 0
if not bom_list:
bom_list = []
seed = (
frappe.qb.from_(bom_item)
.select(bom_item.bom_no.as_("bom"))
.where((bom_item.parent == self.name) & (bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.parent == tree.bom)
.select(bom_item.bom_no)
.where((bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
)
descendants = (
frappe.qb.with_(seed + recursion, "bom_tree", recursive=True).from_(tree).select(tree.bom)
).run(pluck=True)
if self.name not in bom_list:
bom_list.append(self.name)
return [self.name, *descendants]
while count < len(bom_list):
for child_bom in _get_bom_children(bom_list[count]):
if child_bom not in bom_list:
bom_list.append(child_bom)
count += 1
bom_list.reverse()
return bom_list
def company_currency(self):
return erpnext.get_company_currency(self.company)
@@ -1081,7 +1072,7 @@ def _get_price_list_item_rate(args, bom_doc):
if not bom_doc.buying_price_list:
frappe.throw(_("Please select Price List"))
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"doctype": "BOM",
"price_list": bom_doc.buying_price_list,

View File

@@ -67,33 +67,29 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: list[str], batch_name: i
frappe.db.commit() # nosemgrep
def get_ancestor_boms(new_bom: str) -> list:
"""Return every ancestor BOM of `new_bom` (BOMs that consume it, transitively) in one recursive
CTE built with frappe.qb -- portable across postgres and mariadb 10.2+. `UNION` makes it
cycle-safe (it stops once no new BOM is reached); a BOM that is its own ancestor is rejected."""
def get_ancestor_boms(new_bom: str, bom_list: list | None = None) -> list:
"Recursively get all ancestors of BOM."
bom_list = bom_list or []
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("ancestor_boms")
seed = (
parents = (
frappe.qb.from_(bom_item)
.select(bom_item.parent.as_("bom"))
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.bom_no == tree.bom)
.select(bom_item.parent)
.where((bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
.run(as_dict=True)
)
ancestors = (
frappe.qb.with_(seed + recursion, "ancestor_boms", recursive=True).from_(tree).select(tree.bom)
).run(pluck=True)
if new_bom in ancestors:
frappe.throw(_("BOM recursion: {0} cannot be an ancestor of itself").format(new_bom))
for d in parents:
if new_bom == d.parent:
frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
return ancestors
if d.parent not in tuple(bom_list):
bom_list.append(d.parent)
get_ancestor_boms(d.parent, bom_list)
return bom_list
def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:

View File

@@ -149,16 +149,6 @@ class RequiredItemsService:
self.recompute_material_transferred_for_manufacturing(transferred_items)
def refresh_material_transferred_for_manufacturing(self):
"""Recompute material_transferred_for_manufacturing only, without touching per-row
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
"""
if self.doc.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the

View File

@@ -87,12 +87,6 @@ class StatusService:
def update_status(self, status=None):
"""Update status of work order if unknown"""
if self.doc.docstatus == 1:
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
# are reflected immediately, instead of only after the next status update call.
self.doc.refresh_material_transferred_for_manufacturing()
if self.doc.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)

View File

@@ -1003,9 +1003,6 @@ class WorkOrder(Document):
def update_transferred_qty_for_required_items(self):
return RequiredItemsService(self).update_transferred_qty_for_required_items()
def refresh_material_transferred_for_manufacturing(self):
return RequiredItemsService(self).refresh_material_transferred_for_manufacturing()
def update_returned_qty(self):
return RequiredItemsService(self).update_returned_qty()

View File

@@ -2,8 +2,6 @@
# For license information, please see license.txt
from collections import defaultdict
import frappe
from frappe import _
@@ -16,47 +14,29 @@ def execute(filters=None):
def get_data(filters, data):
children_map = fetch_exploded_bom_items(filters.bom)
build_exploded_rows(filters.bom, children_map, data)
get_exploded_items(filters.bom, data)
def fetch_exploded_bom_items(root_bom):
"""Every BOM Item in the exploded tree of `root_bom`, grouped by its parent BOM, in one
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
and fetches each sub-BOM's items only once even when it is reused across the tree."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("exploded_bom")
fields = [
bom_item.parent,
bom_item.qty,
bom_item.bom_no,
bom_item.item_code,
bom_item.item_name,
bom_item.description,
bom_item.uom,
bom_item.idx,
bom_item.is_phantom_item,
]
seed = frappe.qb.from_(bom_item).select(*fields).where(bom_item.parent == root_bom)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.parent == tree.bom_no)
.select(*fields)
.where(tree.bom_no != "")
def get_exploded_items(bom, data, indent=0, qty=1):
exploded_items = frappe.get_all(
"BOM Item",
filters={"parent": bom},
fields=[
"qty",
"bom_no",
"qty",
"item_code",
"item_name",
"description",
"uom",
"idx",
"is_phantom_item",
],
order_by="idx ASC",
)
rows = (
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
).run(as_dict=True)
children_map = defaultdict(list)
for row in rows:
children_map[row.parent].append(row)
return children_map
def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
for item in sorted(children_map.get(bom, []), key=lambda row: row.idx):
for item in exploded_items:
item["indent"] = indent
data.append(
{
"item_code": item.item_code,
@@ -71,7 +51,7 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
}
)
if item.bom_no:
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty)
def get_columns():

View File

@@ -1,101 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.bom_operations_time.bom_operations_time import execute
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
OPERATION = "_Test BOM Ops Time Operation"
WORKSTATION = "_Test BOM Ops Time Workstation"
TIME_IN_MINS = 45
class TestBOMOperationsTime(ERPNextTestSuite):
def setUp(self):
ensure_workstation_and_operation()
self.rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
self.fg_item = make_item(properties={"is_stock_item": 1}).name
self.bom = build_bom_with_operation(self.fg_item, self.rm_item)
def run_report(self, **extra):
filters = frappe._dict({"bom_id": [self.bom.name]})
filters.update(extra)
return execute(filters)[1]
def test_operation_row_appears_with_expected_values(self):
rows = self.run_report()
bom_rows = [row for row in rows if row.name == self.bom.name]
self.assertEqual(len(bom_rows), 1)
row = bom_rows[0]
self.assertEqual(row.item, self.fg_item)
self.assertEqual(row.operation, OPERATION)
self.assertEqual(row.workstation, WORKSTATION)
self.assertEqual(row.time_in_mins, TIME_IN_MINS)
def test_item_code_filter_scopes_to_bom(self):
rows = self.run_report(item_code=self.fg_item)
self.assertTrue(rows)
self.assertTrue(all(row.item == self.fg_item for row in rows))
self.assertIn(self.bom.name, {row.name for row in rows})
def test_workstation_filter(self):
matching = self.run_report(workstation=WORKSTATION)
self.assertIn(self.bom.name, {row.name for row in matching})
other_workstation = ensure_other_workstation()
non_matching = self.run_report(workstation=other_workstation)
self.assertNotIn(self.bom.name, {row.name for row in non_matching})
def test_draft_bom_excluded(self):
draft_bom = build_bom_with_operation(
make_item(properties={"is_stock_item": 1}).name, self.rm_item, do_not_submit=True
)
rows = execute(frappe._dict({"bom_id": [draft_bom.name]}))[1]
self.assertEqual(rows, [])
def ensure_workstation_and_operation():
if not frappe.db.exists("Workstation", WORKSTATION):
frappe.get_doc({"doctype": "Workstation", "workstation_name": WORKSTATION}).insert(
ignore_permissions=True
)
if not frappe.db.exists("Operation", OPERATION):
frappe.get_doc({"doctype": "Operation", "name": OPERATION, "workstation": WORKSTATION}).insert(
ignore_permissions=True
)
def ensure_other_workstation():
name = "_Test BOM Ops Time Workstation 2"
if not frappe.db.exists("Workstation", name):
frappe.get_doc({"doctype": "Workstation", "workstation_name": name}).insert(ignore_permissions=True)
return name
def build_bom_with_operation(fg_item, rm_item, do_not_submit=False):
bom = make_bom(
item=fg_item,
raw_materials=[rm_item],
with_operations=1,
do_not_save=True,
)
bom.append(
"operations",
{
"operation": OPERATION,
"workstation": WORKSTATION,
"time_in_mins": TIME_IN_MINS,
"hour_rate": 100,
},
)
bom.insert(ignore_permissions=True)
if not do_not_submit:
bom.submit()
return bom

View File

@@ -1,92 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, get_datetime, today
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.manufacturing.report.downtime_analysis.downtime_analysis import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestDowntimeAnalysis(ERPNextTestSuite):
def setUp(self):
self.workstation = make_workstation(workstation="_Test Downtime Workstation").name
self.other_workstation = make_workstation(workstation="_Test Downtime Workstation 2").name
self.operator = make_employee("test_downtime_operator@example.com", company="_Test Company")
# from_time / to_time are two hours apart -> downtime of 120 minutes (2 hours).
self.from_time = get_datetime(f"{today()} 09:00:00")
self.to_time = get_datetime(f"{today()} 11:00:00")
self.entry = self.make_downtime_entry(self.workstation)
def make_downtime_entry(self, workstation, **extra):
values = {
"doctype": "Downtime Entry",
"workstation": workstation,
"operator": self.operator,
"from_time": self.from_time,
"to_time": self.to_time,
"stop_reason": "Machine malfunction",
}
values.update(extra)
return frappe.get_doc(values).insert()
def run_report(self, **extra):
filters = frappe._dict(
{
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
}
)
filters.update(extra)
return execute(filters)[1]
def row_for_entry(self, rows, name):
return next((row for row in rows if row.get("name") == name), None)
def test_downtime_is_computed_in_hours(self):
# validate() stores downtime in minutes; the report converts it to hours.
self.assertEqual(self.entry.downtime, 120)
row = self.row_for_entry(self.run_report(), self.entry.name)
self.assertIsNotNone(row, "Downtime Entry not present in report output")
self.assertEqual(row.get("workstation"), self.workstation)
self.assertEqual(row.get("operator"), self.operator)
self.assertEqual(row.get("stop_reason"), "Machine malfunction")
self.assertEqual(row.get("downtime"), 2.0)
def test_workstation_filter_scopes_rows(self):
other = self.make_downtime_entry(self.other_workstation)
rows = self.run_report(workstation=self.workstation)
names = {row.get("name") for row in rows}
self.assertIn(self.entry.name, names)
self.assertNotIn(other.name, names)
self.assertTrue(all(row.get("workstation") == self.workstation for row in rows))
def test_date_range_excludes_out_of_window_entries(self):
# The report filters from_time >= from_date and to_time <= to_date; a window
# ending before the entry's from_time must exclude it.
rows = self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
self.assertIsNone(self.row_for_entry(rows, self.entry.name))
def test_chart_aggregates_downtime_per_workstation(self):
self.make_downtime_entry(self.workstation)
chart = execute(
frappe._dict(
{
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
"workstation": self.workstation,
}
)
)[3]
self.assertIn(self.workstation, chart["data"]["labels"])
index = chart["data"]["labels"].index(self.workstation)
# Two entries of 2 hours each for this workstation -> 4 hours aggregated.
self.assertEqual(chart["data"]["datasets"][0]["values"][index], 4.0)

View File

@@ -1,118 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import nowdate
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.process_loss_report.process_loss_report import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessLossReport(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": nowdate(),
"to_date": nowdate(),
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, work_order):
for row in data:
if row.get("name") == work_order:
return row
return None
def make_manufactured_work_order(self, planned_qty, produced_qty):
"""Create a submitted WO and manufacture `produced_qty` of `planned_qty`.
The difference is booked as process loss on the Manufacture stock entry,
which propagates to the work order's `process_loss_qty`.
"""
wo_order = make_wo_order_test_record(production_item="_Test FG Item", qty=planned_qty)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=100
)
transfer = frappe.get_doc(
make_stock_entry(wo_order.name, "Material Transfer for Manufacture", planned_qty)
)
for d in transfer.get("items"):
d.s_warehouse = "Stores - _TC"
transfer.insert()
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", planned_qty))
# Reduce the finished good qty below fg_completed_qty so the difference is
# recorded as process loss.
process_loss_qty = planned_qty - produced_qty
if process_loss_qty:
for d in manufacture.get("items"):
if d.is_finished_item:
d.qty = produced_qty
d.transfer_qty = produced_qty * (d.conversion_factor or 1)
manufacture.insert()
manufacture.submit()
wo_order.reload()
return wo_order
def test_work_order_with_process_loss_is_listed(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
self.assertEqual(wo_order.process_loss_qty, 1)
self.assertEqual(wo_order.produced_qty, 4)
data = self.run_report(work_order=wo_order.name)
row = self.find_row(data, wo_order.name)
self.assertIsNotNone(row, "Work order with process loss should appear in the report")
self.assertEqual(row.production_item, "_Test FG Item")
self.assertEqual(row.qty_to_manufacture, 5)
self.assertEqual(row.produced_qty, 4)
self.assertEqual(row.process_loss_qty, 1)
# total_pl_value = process_loss_qty * (total_fg_value / qty_to_manufacture)
expected_pl_value = row.process_loss_qty * (row.total_fg_value / row.qty_to_manufacture)
self.assertAlmostEqual(row.total_pl_value, expected_pl_value)
self.assertGreater(row.total_pl_value, 0)
def test_work_order_without_process_loss_is_not_listed(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=5)
self.assertEqual(wo_order.process_loss_qty, 0)
self.assertEqual(wo_order.produced_qty, 5)
data = self.run_report(work_order=wo_order.name)
self.assertIsNone(
self.find_row(data, wo_order.name),
"Work order that produced the full planned qty should not appear (no loss)",
)
def test_item_and_work_order_filters_are_ineffective(self):
"""BUG: the `item` and `work_order` filters in process_loss_report.get_data
call `query.where(...)` without reassigning the result. frappe's query
builder is immutable, so `.where()` returns a new query and these extra
conditions are silently dropped. A non-matching item filter therefore fails
to exclude the row. This test documents the current (buggy) behaviour; if the
report is fixed to reassign the query, update the assertion below to
`assertIsNone`.
"""
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
# A non-matching item filter should exclude the row, but currently does not.
data = self.run_report(item="_Test FG Item 2")
self.assertIsNotNone(
self.find_row(data, wo_order.name),
"Filter bug regressed/fixed: `item` filter now takes effect - update this test",
)

View File

@@ -492,4 +492,3 @@ erpnext.patches.v16_0.rename_subscription_billing_period_fields
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
erpnext.patches.v16_0.set_default_close_opportunity_after_days
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v16_0.backfill_pick_list_transferred_qty

View File

@@ -1,58 +0,0 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import flt
def execute():
StockEntry = frappe.qb.DocType("Stock Entry")
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
pick_lists = (
frappe.qb.from_(StockEntry)
.select(StockEntry.pick_list)
.distinct()
.where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1))
).run(pluck=True)
if not pick_lists:
return
rows = (
frappe.qb.from_(StockEntryDetail)
.join(StockEntry)
.on(StockEntryDetail.parent == StockEntry.name)
.select(
StockEntry.pick_list,
StockEntryDetail.item_code,
StockEntryDetail.s_warehouse,
Sum(StockEntryDetail.transfer_qty).as_("qty"),
)
.where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1))
.groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse)
).run(as_dict=True)
transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows}
items = frappe.get_all(
"Pick List Item",
filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)},
fields=["name", "parent", "item_code", "warehouse", "picked_qty"],
order_by="idx",
)
updates = {}
for row in items:
key = (row.parent, row.item_code, row.warehouse)
available = transferred.get(key, 0)
if available <= 0:
continue
qty = min(flt(row.picked_qty), available)
transferred[key] = available - qty
updates[row.name] = {"transferred_qty": qty}
if not updates:
return
frappe.db.auto_commit_on_many_writes = True
frappe.db.bulk_update("Pick List Item", updates)
frappe.db.auto_commit_on_many_writes = False

View File

@@ -9,7 +9,7 @@ from frappe import _, throw
from frappe.desk.form.assign_to import clear, close_all_assignments
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Max, Min, Sum
from frappe.utils import add_days, add_to_date, date_diff, flt, get_link_to_form, getdate, today
from frappe.utils import add_days, add_to_date, cstr, date_diff, flt, get_link_to_form, getdate, today
from frappe.utils.data import format_date
from frappe.utils.nestedset import NestedSet
@@ -247,32 +247,25 @@ class Task(NestedSet):
def check_recursion(self):
if self.flags.ignore_recursion_check:
return
# "Task Depends On" is a directed edge (parent depends on `task`); a cycle exists if this
# task is reachable from itself along either direction. One recursive CTE per direction
# fetches the whole reachable set in a single query -- UNION makes it cycle-safe at any
# depth, so unlike the old per-node BFS it needs no arbitrary depth cap.
for select_field, filter_field in (("task", "parent"), ("parent", "task")):
if self._reaches_self(select_field, filter_field):
frappe.throw(_("Circular Reference Error"), CircularReferenceError)
check_list = [["task", "parent"], ["parent", "task"]]
for d in check_list:
task_list, count = [self.name], 0
while len(task_list) > count:
tasks = frappe.get_all(
"Task Depends On",
filters={d[1]: cstr(task_list[count])},
fields=[d[0]],
as_list=True,
)
count = count + 1
for b in tasks:
if b[0] == self.name:
frappe.throw(_("Circular Reference Error"), CircularReferenceError)
if b[0]:
task_list.append(b[0])
def _reaches_self(self, select_field: str, filter_field: str) -> bool:
depends_on = frappe.qb.DocType("Task Depends On")
tree = frappe.qb.Table("dependency_tree")
seed = (
frappe.qb.from_(depends_on)
.select(depends_on[select_field].as_("node"))
.where(depends_on[filter_field] == self.name)
)
recursion = (
frappe.qb.from_(depends_on)
.join(tree)
.on(depends_on[filter_field] == tree.node)
.select(depends_on[select_field])
)
reachable = (
frappe.qb.with_(seed + recursion, "dependency_tree", recursive=True).from_(tree).select(tree.node)
).run(pluck=True)
return self.name in reachable
if count == 15:
break
def reschedule_dependent_tasks(self):
end_date = self.exp_end_date or self.act_end_date

View File

@@ -2132,7 +2132,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
frappe.call({
method: "erpnext.stock.get_item_details.get_batch_based_item_price",
args: {
ctx: params,
pctx: params,
item_code: row.item_code,
},
callback: function (r) {

View File

@@ -19,101 +19,6 @@ frappe.setup.on("before_load", function () {
});
erpnext.setup.slides_settings = [
{
// Persona — help us tailor the setup
name: "persona",
title: __("A little about you"),
// subtitle shown under the title
help: __("A few quick questions so we can set things up the way you work."),
fields: [
{
fieldname: "persona_implementing_for",
label: __("Who are you setting this up for?"),
fieldtype: "Select",
options: ["", "My own business", "A company I work for", "A client I'm consulting for"].join(
"\n"
),
reqd: 1,
},
{
fieldname: "persona_company_size",
label: __("How big is the team?"),
fieldtype: "Select",
options: ["", "110", "1150", "51200", "2011,000", "1,000+"].join("\n"),
reqd: 1,
},
{
fieldname: "persona_industry",
label: __("What kind of work do you do?"),
fieldtype: "Select",
options: [
"",
"Manufacturing",
"Retail",
"Wholesale / Distribution",
"E-commerce",
"Services / Consulting",
"Construction / Real Estate",
"Technology / Software",
"Healthcare",
"Education",
"Agriculture",
"Food & Beverage",
"Non Profit",
"Other",
].join("\n"),
reqd: 1,
},
{
fieldname: "persona_current_system",
label: __("What do you use today?"),
fieldtype: "Select",
options: [
"",
"Tally",
"QuickBooks",
"Zoho",
"Sage",
"SAP",
"Microsoft Dynamics",
"Oracle NetSuite",
"Xero",
"Excel / Spreadsheets",
"Nothing yet - starting fresh",
"Other",
].join("\n"),
reqd: 1,
},
{
fieldtype: "Section Break",
description: __("Select the modules that you plan to implement"),
},
{ fieldname: "module_accounting", label: __("Accounting"), fieldtype: "Check" },
{ fieldname: "module_stock", label: __("Stock"), fieldtype: "Check" },
{ fieldtype: "Column Break" },
{ fieldname: "module_manufacturing", label: __("Manufacturing"), fieldtype: "Check" },
{ fieldname: "module_projects", label: __("Project Management"), fieldtype: "Check" },
],
onload: function (slide) {
this.bind_industry_modules(slide);
},
bind_industry_modules: function (slide) {
let me = this;
slide.get_input("persona_industry").on("change", function () {
me.apply_industry_modules(slide);
});
},
apply_industry_modules: function (slide) {
let industry = slide.get_field("persona_industry").get_value();
let modules = erpnext.setup.industry_modules[industry] || ["accounting"];
["accounting", "stock", "manufacturing", "projects"].forEach(function (module) {
slide.get_field("module_" + module).set_value(modules.includes(module) ? 1 : 0);
});
},
},
{
// Organization
name: "organization",
@@ -338,24 +243,6 @@ erpnext.setup.slides_settings = [
},
];
// Modules pre-selected on the persona slide based on the chosen industry.
// Keys must match the persona_industry option values. Accounting is always on.
erpnext.setup.industry_modules = {
Manufacturing: ["accounting", "stock", "manufacturing"],
Retail: ["accounting", "stock"],
"Wholesale / Distribution": ["accounting", "stock"],
"E-commerce": ["accounting", "stock"],
"Services / Consulting": ["accounting", "projects"],
"Construction / Real Estate": ["accounting", "stock", "projects"],
"Technology / Software": ["accounting", "projects"],
Healthcare: ["accounting", "stock"],
Education: ["accounting", "projects"],
Agriculture: ["accounting", "stock"],
"Food & Beverage": ["accounting", "stock", "manufacturing"],
"Non Profit": ["accounting", "projects"],
Other: ["accounting"],
};
// Source: https://en.wikipedia.org/wiki/Fiscal_year
// default 1st Jan - 31st Dec

View File

@@ -21,6 +21,9 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
)
target_doc.quotation_to = "Customer"
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
price_list, currency = frappe.db.get_value(
"Customer", {"name": source_name}, ["default_price_list", "default_currency"]
@@ -30,10 +33,6 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
if currency:
target_doc.currency = currency
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
return target_doc

View File

@@ -5,7 +5,7 @@
import json
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import flt
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
@@ -14,53 +14,12 @@ from erpnext.selling.doctype.customer.customer import (
get_customer_outstanding,
)
from erpnext.selling.doctype.customer.mapper import (
make_quotation,
parse_full_name,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestCustomer(ERPNextTestSuite):
def test_quotation_from_customer_uses_actual_exchange_rate(self):
company = "_Test Company"
company_currency = frappe.get_cached_value("Company", company, "default_currency")
foreign_currency = "USD" if company_currency != "USD" else "EUR"
frappe.defaults.set_user_default("company", company)
self.addCleanup(frappe.defaults.clear_user_default, "company")
# Seed a deterministic rate so the test does not depend on the live exchange-rate API.
rate = 83.0
exchange = frappe.get_doc(
{
"doctype": "Currency Exchange",
"date": nowdate(),
"from_currency": foreign_currency,
"to_currency": company_currency,
"exchange_rate": rate,
"for_selling": 1,
"for_buying": 1,
}
).insert(ignore_if_duplicate=True)
self.addCleanup(frappe.delete_doc, "Currency Exchange", exchange.name, force=1)
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "_Test Customer FX Quotation",
"customer_type": "Company",
"default_currency": foreign_currency,
}
).insert()
self.addCleanup(frappe.delete_doc, "Customer", customer.name, force=1)
quotation = make_quotation(customer.name)
self.assertEqual(quotation.currency, foreign_currency)
self.assertNotEqual(flt(quotation.conversion_rate), 1.0)
self.assertNotEqual(flt(quotation.conversion_rate), 0.0)
self.assertEqual(flt(quotation.conversion_rate), rate)
def test_get_customer_name_dedupes_with_numeric_suffix(self):
# When a customer name already exists, get_customer_name appends "- <max suffix + 1>". The
# Postgres branch extracts the suffix with regexp_replace/NULLIF/CAST (pypika's Substring cannot

View File

@@ -290,7 +290,7 @@ class TestQuotation(ERPNextTestSuite):
def test_gross_profit(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import insert_item_price
from erpnext.stock.get_item_details import ItemDetailsCtx, insert_item_price
item_doc = make_item("_Test Item for Gross Profit", {"is_stock_item": 1})
item_code = item_doc.name
@@ -299,7 +299,7 @@ class TestQuotation(ERPNextTestSuite):
selling_price_list = frappe.get_all("Price List", filters={"selling": 1}, limit=1)[0].name
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1)
insert_item_price(
frappe._dict(
ItemDetailsCtx(
{
"item_code": item_code,
"price_list": selling_price_list,

View File

@@ -26,7 +26,7 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
get_sre_reserved_qty_details_for_voucher,
get_ssb_bundle_for_voucher,
)
from erpnext.stock.get_item_details import get_bin_details, get_price_list_rate
from erpnext.stock.get_item_details import ItemDetailsCtx, get_bin_details, get_price_list_rate
def get_requested_item_qty(sales_order: str) -> dict:
@@ -105,7 +105,7 @@ def make_material_request(source_name: str, target_doc: str | Document | None =
target.item_code, target.warehouse, source_parent.company, True
).get("actual_qty", 0)
ctx = frappe._dict(target.as_dict().copy())
ctx = ItemDetailsCtx(target.as_dict().copy())
ctx.update(
{
"company": source_parent.get("company"),

View File

@@ -464,9 +464,6 @@ def set_customer_info(fieldname: str, customer: str, value: str = ""):
& (DynamicLink.link_doctype == "Customer")
)
.orderby(Contact.is_primary_contact, order=Order.desc)
# tiebreaker: contacts tie on is_primary_contact (the common no-primary case) ->
# pick the same one on MariaDB and Postgres
.orderby(DynamicLink.parent, order=Order.asc)
)
contacts = query.run(pluck=DynamicLink.parent)

View File

@@ -1,123 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import (
create_dn_against_so,
make_sales_order,
)
from erpnext.selling.report.item_wise_sales_history.item_wise_sales_history import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseSalesHistory(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)
def so_row(self, so_name, **extra):
data = self.run_report(**extra)[1]
return next(row for row in data if row["sales_order"] == so_name)
def test_sales_order_line_shown_with_values(self):
so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01")
row = self.so_row(so.name)
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["quantity"], 10)
self.assertEqual(row["rate"], 100)
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["customer"], "_Test Customer")
def test_draft_sales_order_excluded(self):
so = make_sales_order(transaction_date="2026-06-01", do_not_submit=True)
names = {row["sales_order"] for row in self.run_report()[1]}
self.assertNotIn(so.name, names)
def test_date_range_filters_on_transaction_date(self):
so = make_sales_order(transaction_date="2026-06-01")
in_range = {
row["sales_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
}
self.assertIn(so.name, in_range)
out_of_range = {
row["sales_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
}
self.assertNotIn(so.name, out_of_range)
def test_item_code_filter(self):
so = make_sales_order(
transaction_date="2026-06-01",
item_list=[
{"item_code": "_Test Item", "qty": 5, "rate": 100, "warehouse": "_Test Warehouse - _TC"},
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
],
)
item_codes = {row["item_code"] for row in self.run_report(item_code="_Test Item 2")[1]}
self.assertEqual(item_codes, {"_Test Item 2"})
# the filtered-out line of the same order must not leak in
self.assertTrue(
all(row["sales_order"] == so.name for row in self.run_report(item_code="_Test Item 2")[1])
)
def test_customer_filter(self):
make_sales_order(customer="_Test Customer 1", transaction_date="2026-06-01")
make_sales_order(customer="_Test Customer 2", transaction_date="2026-06-01")
customers = {row["customer"] for row in self.run_report(customer="_Test Customer 1")[1]}
self.assertEqual(customers, {"_Test Customer 1"})
def test_delivered_quantity_reflects_delivery(self):
so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01")
create_dn_against_so(so.name, delivered_qty=4)
self.assertEqual(self.so_row(so.name)["delivered_quantity"], 4)
def test_billed_amount_reflects_invoice(self):
so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01")
si = make_sales_invoice(so.name)
si.insert()
si.submit()
self.assertEqual(self.so_row(so.name)["billed_amount"], 1000)
def test_amounts_reported_in_company_currency(self):
# a USD order must report rate/amount converted to the company's currency (base_* fields)
so = make_sales_order(
do_not_save=True,
currency="USD",
qty=10,
rate=100,
transaction_date="2026-06-01",
)
so.conversion_rate = 80
so.insert()
so.submit()
row = self.so_row(so.name)
self.assertEqual(row["rate"], 8000) # 100 USD * 80
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
def test_chart_aggregates_amount_per_item(self):
make_sales_order(item_code="_Test Item", qty=2, rate=100, transaction_date="2026-06-01")
make_sales_order(item_code="_Test Item", qty=3, rate=100, transaction_date="2026-06-01")
chart = self.run_report(item_code="_Test Item")[3]
labels = chart["data"]["labels"]
values = chart["data"]["datasets"][0]["values"]
self.assertIn("_Test Item", labels)
# 2*100 + 3*100 aggregated for the item
self.assertEqual(values[labels.index("_Test Item")], 500)

View File

@@ -130,7 +130,6 @@
"column_break_32",
"stock_adjustment_account",
"default_purchase_price_variance_account",
"default_manufacturing_variance_account",
"stock_received_but_not_billed",
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
@@ -500,18 +499,7 @@
"ignore_user_permissions": 1,
"label": "Default Purchase Price Variance Account",
"no_copy": 1,
"options": "Account",
"show_description_on_click": 1
},
{
"description": "For Standard Cost items: the Manufacture/Repack consumed cost vs standard rate difference is booked here.",
"fieldname": "default_manufacturing_variance_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Manufacturing Variance Account",
"no_copy": 1,
"options": "Account",
"show_description_on_click": 1
"options": "Account"
},
{
"fieldname": "column_break_32",
@@ -1026,7 +1014,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2026-07-01 11:48:07.853494",
"modified": "2026-06-26 10:05:00.000000",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -80,7 +80,6 @@ class Company(NestedSet):
default_inventory_account: DF.Link | None
default_letter_head: DF.Link | None
default_letter_head_report: DF.Link | None
default_manufacturing_variance_account: DF.Link | None
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None

View File

@@ -4,13 +4,12 @@
import frappe
from frappe import _
from frappe.utils.telemetry import capture
from erpnext.setup.demo import setup_demo_data
from erpnext.setup.setup_wizard.operations import install_fixtures as fixtures
def get_setup_stages(args=None): # nosemgrep
def get_setup_stages(args=None):
stages = [
{
"status": _("Installing presets"),
@@ -29,13 +28,6 @@ def get_setup_stages(args=None): # nosemgrep
{"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")},
],
},
{
"status": _("Personalizing your setup"),
"fail_msg": _("Failed to personalize your setup"),
"tasks": [
{"fn": capture_user_persona, "args": args, "fail_msg": _("Failed to personalize your setup")}
],
},
]
if args.get("setup_demo"):
@@ -50,38 +42,15 @@ def get_setup_stages(args=None): # nosemgrep
return stages
def capture_user_persona(args): # nosemgrep
"""Send the persona answers captured on the setup slide to telemetry."""
if not args:
return
capture(
"user_persona_submitted",
"erpnext",
properties={
"implementing_for": args.get("persona_implementing_for"),
"company_size": args.get("persona_company_size"),
"industry": args.get("persona_industry"),
"current_system": args.get("persona_current_system"),
"module_accounting": bool(args.get("module_accounting")),
"module_stock": bool(args.get("module_stock")),
"module_manufacturing": bool(args.get("module_manufacturing")),
"module_projects": bool(args.get("module_projects")),
"country": args.get("country"),
"language": args.get("language"),
},
)
def stage_fixtures(args): # nosemgrep
def stage_fixtures(args):
fixtures.install(args.get("country"))
def setup_company(args): # nosemgrep
def setup_company(args):
fixtures.install_company(args)
def setup_defaults(args): # nosemgrep
def setup_defaults(args):
fixtures.install_defaults(frappe._dict(args))
@@ -90,7 +59,7 @@ def setup_demo(args): # nosemgrep
# Only for programmatical use
def setup_complete(args=None): # nosemgrep
def setup_complete(args=None):
stage_fixtures(args)
setup_company(args)
setup_defaults(args)

View File

@@ -19,7 +19,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
get_batch_from_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
from erpnext.tests.utils import ERPNextTestSuite
@@ -595,7 +595,7 @@ class TestBatch(ERPNextTestSuite):
company = "_Test Company with perpetual inventory"
currency = frappe.get_cached_value("Company", company, "default_currency")
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"item_code": "_Test Batch Price Item",
"company": company,

View File

@@ -1074,141 +1074,62 @@ $.extend(erpnext.item, {
function make_fields_from_attribute_values(attr_dict) {
let fields = [];
let attributes = frm.doc.attributes.filter((row) => !row.disabled);
attributes.forEach((row, i) => {
let name = row.attribute;
let att_key = frm.doc.attributes.map((idx) => idx.attribute);
att_key.forEach((name, i) => {
if (i % 3 === 0) {
fields.push({ fieldtype: "Section Break" });
}
fields.push({ fieldtype: "Column Break" });
fields.push({ fieldtype: "Column Break", label: name });
fields.push({
fieldtype: "MultiSelectPills",
label: name,
fieldname: frappe.scrub(name),
placeholder: __("Search values..."),
get_data: (txt) => get_attribute_suggestions(attr_dict[name], txt),
onchange: update_primary_action,
fieldtype: "Data",
placeholder: "Search",
fieldname: `search_${frappe.scrub(name)}`,
onchange: function (e) {
let value = e.target.value;
let result = attr_dict[name].filter((attr_value) =>
attr_value.toString().toLowerCase().includes(value.toLowerCase())
);
attr_dict[name].forEach((attr_value) => {
if (result.includes(attr_value)) {
me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 0);
} else {
me.multiple_variant_dialog.set_df_property(attr_value, "hidden", 1);
}
});
},
});
attr_dict[name].forEach((value) => {
fields.push({
fieldtype: "Check",
label: value,
fieldname: value,
default: 0,
onchange: function () {
let selected_attributes = get_selected_attributes();
let lengths = Object.keys(selected_attributes).map((key) => {
return selected_attributes[key].length;
});
if (!lengths.length) {
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
me.multiple_variant_dialog.disable_primary_action();
} else {
let no_of_combinations = lengths.reduce((a, b) => a * b, 1);
let msg;
if (no_of_combinations === 1) {
msg = __("Make {0} Variant", [no_of_combinations]);
} else {
msg = __("Make {0} Variants", [no_of_combinations]);
}
me.multiple_variant_dialog.get_primary_btn().html(msg);
me.multiple_variant_dialog.enable_primary_action();
}
},
});
});
});
return fields;
}
function get_attribute_suggestions(spec, txt) {
if (!spec) return [];
return Array.isArray(spec) ? filter_list(spec, txt) : numeric_suggestions(spec, txt);
}
// Cap matches so a long value list never hands everything to Awesomplete,
// which would freeze the browser.
function filter_list(values, txt) {
txt = (txt || "").toLowerCase();
let matches = [];
for (let value of values) {
if (!txt || value.toLowerCase().includes(txt)) {
matches.push(value);
if (matches.length >= 50) break;
}
}
return matches;
}
// Numeric ranges aren't enumerated. With no input, preview the first few
// values; once the user types, accept it only if it lies on the increment
// within [from, to]. Both paths are cheap even for huge ranges.
function numeric_suggestions(range, txt) {
let { from_range: from, to_range: to, increment } = range;
if (!(increment > 0) || from > to) return [];
txt = (txt || "").trim();
if (!txt) {
let preview = [];
for (
let value = from;
value <= to && preview.length < 50;
value = flt(value + increment, 6)
) {
preview.push(String(value));
}
return preview;
}
return is_valid_attribute_value(range, txt) ? [String(flt(txt, 6))] : [];
}
function is_valid_attribute_value(spec, value) {
if (!spec || !value) return false;
if (Array.isArray(spec)) return spec.includes(value);
let { from_range: from, to_range: to, increment } = spec;
if (!(increment > 0)) return false;
// Reject anything that isn't cleanly a number ("abc", "5000xyz", "");
// flt would coerce these to 0 and wrongly accept them.
let text = String(value).trim();
let num = Number(text);
if (text === "" || !Number.isFinite(num)) return false;
if (num < from || num > to) return false;
let steps = (num - from) / increment;
return Math.abs(Math.round(steps) - steps) <= 1e-6;
}
// Block variant creation if anything is wrong: an invalid committed pill, or
// text typed but not added as a pill (which get_selected_attributes would
// otherwise drop silently). The user must fix each before creation proceeds.
function validate_selected_attributes() {
let errors = [];
frm.doc.attributes.forEach((row) => {
if (row.disabled) return;
let field = me.multiple_variant_dialog.get_field(frappe.scrub(row.attribute));
if (!field) return;
let attribute = frappe.utils.escape_html(row.attribute);
let spec = attr_val_fields[row.attribute];
let invalid = [
...new Set((field.get_value() || []).filter((v) => !is_valid_attribute_value(spec, v))),
];
if (invalid.length) {
let values = invalid.map(frappe.utils.escape_html).join(", ");
errors.push(__("{0}: remove invalid value(s) {1}", [attribute, values]));
}
let pending = (field.$input?.val() || "").trim();
if (pending) {
let value = frappe.utils.escape_html(pending);
errors.push(
__("{0}: select the typed value {1} from the list or clear it", [attribute, value])
);
}
});
if (errors.length) {
frappe.throw({
title: __("Invalid Attribute Values"),
message: errors.join("<br>"),
indicator: "red",
});
}
}
function update_primary_action() {
let selected_attributes = get_selected_attributes();
let counts = Object.keys(selected_attributes).map((key) => selected_attributes[key].length);
if (!counts.length) {
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
me.multiple_variant_dialog.disable_primary_action();
} else {
let no_of_combinations = counts.reduce((a, b) => a * b, 1);
let msg =
no_of_combinations === 1
? __("Make {0} Variant", [no_of_combinations])
: __("Make {0} Variants", [no_of_combinations]);
me.multiple_variant_dialog.get_primary_btn().html(msg);
me.multiple_variant_dialog.enable_primary_action();
}
}
function make_and_show_dialog(fields) {
me.multiple_variant_dialog = new frappe.ui.Dialog({
title: __("Select Attribute Values"),
@@ -1234,8 +1155,6 @@ $.extend(erpnext.item, {
});
me.multiple_variant_dialog.set_primary_action(__("Create Variants"), () => {
validate_selected_attributes();
let selected_attributes = get_selected_attributes();
let use_template_image = me.multiple_variant_dialog.get_value("use_template_image");
@@ -1263,70 +1182,72 @@ $.extend(erpnext.item, {
});
});
$($(me.multiple_variant_dialog.$wrapper.find(".form-column")).find(".frappe-control")).css(
"margin-bottom",
"0px"
);
me.multiple_variant_dialog.disable_primary_action();
me.multiple_variant_dialog.clear();
me.multiple_variant_dialog.show();
me.multiple_variant_dialog.$wrapper
.find("div[data-fieldname^='search_']")
.find(".clearfix")
.hide();
}
function get_selected_attributes() {
let selected_attributes = {};
frm.doc.attributes.forEach((row) => {
if (row.disabled) return;
let values = me.multiple_variant_dialog.get_value(frappe.scrub(row.attribute));
if (values && values.length) {
selected_attributes[row.attribute] = values;
me.multiple_variant_dialog.$wrapper.find(".form-column").each((i, col) => {
if (i === 0) return;
let attribute_name = $(col).find(".column-label").html().trim();
selected_attributes[attribute_name] = [];
let checked_opts = $(col).find(".checkbox input");
checked_opts.each((i, opt) => {
if ($(opt).is(":checked")) {
selected_attributes[attribute_name].push($(opt).attr("data-fieldname"));
}
});
if (!selected_attributes[attribute_name].length) {
delete selected_attributes[attribute_name];
}
});
return selected_attributes;
}
frm.doc.attributes.forEach(function (d) {
if (!d.disabled) {
let p = new Promise((resolve) => {
// Read the numeric configuration from the Item Attribute master
// instead of the variant attribute row, which may be stale or
// blank if the attribute was made numeric after it was added here.
frappe.db
.get_value("Item Attribute", d.attribute, [
"numeric_values",
"from_range",
"to_range",
"increment",
])
.then((res) => {
let attr = res.message || {};
if (!attr.numeric_values) {
frappe
.call({
method: "frappe.client.get_list",
args: {
doctype: "Item Attribute Value",
filters: [["parent", "=", d.attribute]],
fields: ["attribute_value"],
limit_page_length: 0,
parent: "Item Attribute",
order_by: "idx",
},
})
.then((r) => {
attr_val_fields[d.attribute] = (r.message || []).map(
(row) => row.attribute_value
);
resolve();
if (!d.numeric_values) {
frappe
.call({
method: "frappe.client.get_list",
args: {
doctype: "Item Attribute Value",
filters: [["parent", "=", d.attribute]],
fields: ["attribute_value"],
limit_page_length: 0,
parent: "Item Attribute",
order_by: "idx",
},
})
.then((r) => {
if (r.message) {
attr_val_fields[d.attribute] = r.message.map(function (d) {
return d.attribute_value;
});
} else {
// Store the range instead of enumerating it; a large range
// (e.g. 1-100000) is slow to build and to search. Values are
// validated against the range on demand while typing.
attr_val_fields[d.attribute] = {
from_range: flt(attr.from_range),
to_range: flt(attr.to_range),
increment: flt(attr.increment),
};
resolve();
}
});
resolve();
}
});
} else {
let values = [];
for (var i = d.from_range; i <= d.to_range; i = flt(i + d.increment, 6)) {
values.push(i);
}
attr_val_fields[d.attribute] = values;
resolve();
}
});
promises.push(p);

View File

@@ -1372,8 +1372,7 @@ def get_purchase_voucher_details(doctype, item_code, document_name=None):
query = query.select(parent_doc.transaction_date)
query = query.orderby(parent_doc.transaction_date, parent_doc.name, order=Order.desc)
# only the latest ([0]) row is ever used, so fetch just that instead of every purchase of the item
return query.limit(1).run(as_dict=1)
return query.run(as_dict=1)
def check_stock_uom_with_bin(item, stock_uom):
@@ -1763,13 +1762,3 @@ def get_default_warehouse_for_opening_stock(item, company: str, warehouse: str |
"No warehouse found for company {0}. Please set a Default Warehouse in Item Defaults or Stock Settings."
).format(frappe.bold(company))
)
def on_doctype_update():
if frappe.db.db_type == "postgres":
# The Item link-search (erpnext.controllers.queries.item_query) filters
# `item_code/item_name LIKE '%txt%'` -- a leading-wildcard LIKE no btree can serve. pg_trgm
# GIN indexes accelerate it. Item is read-heavy/write-light master data, so GIN maintenance
# cost is negligible. Postgres-only (`using` is a no-op on MariaDB, which has its own FULLTEXT).
frappe.db.add_index("Item", ["item_code"], using="gin_trgm")
frappe.db.add_index("Item", ["item_name"], using="gin_trgm")

View File

@@ -25,7 +25,7 @@ from erpnext.stock.doctype.item.item import (
validate_is_stock_item,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
from erpnext.tests.utils import ERPNextTestSuite
@@ -158,7 +158,7 @@ class TestItem(ERPNextTestSuite):
currency = frappe.get_cached_value("Company", company, "default_currency")
details = get_item_details(
frappe._dict(
ItemDetailsCtx(
{
"item_code": "_Test Item",
"company": company,
@@ -188,7 +188,7 @@ class TestItem(ERPNextTestSuite):
create_fixed_asset_item()
details = get_item_details(
frappe._dict(
ItemDetailsCtx(
{
"item_code": "Macbook Pro",
"company": "_Test Company",
@@ -201,7 +201,7 @@ class TestItem(ERPNextTestSuite):
frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", "1")
details = get_item_details(
frappe._dict(
ItemDetailsCtx(
{
"item_code": "Macbook Pro",
"company": "_Test Company",
@@ -291,7 +291,7 @@ class TestItem(ERPNextTestSuite):
for data in expected_item_tax_template:
details = get_item_details(
frappe._dict(
ItemDetailsCtx(
{
"item_code": data["item_code"],
"tax_category": data["tax_category"],
@@ -343,7 +343,7 @@ class TestItem(ERPNextTestSuite):
"cost_center": "_Test Cost Center 2 - _TC", # from item group
}
sales_item_details = get_item_details(
frappe._dict(
ItemDetailsCtx(
{
"item_code": "Test Item With Defaults",
"company": "_Test Company",
@@ -368,7 +368,7 @@ class TestItem(ERPNextTestSuite):
"cost_center": "_Test Write Off Cost Center - _TC", # from item
}
purchase_item_details = get_item_details(
frappe._dict(
ItemDetailsCtx(
{
"item_code": "Test Item With Defaults",
"company": "_Test Company",

View File

@@ -1,13 +1,4 @@
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Item Attribute", {
numeric_values(frm) {
// Numeric attributes have no discrete values; drop the rows so their
// mandatory Attribute Value / Abbreviation don't block the save.
if (frm.doc.numeric_values) {
frm.clear_table("item_attribute_values");
frm.refresh_field("item_attribute_values");
}
},
});
frappe.ui.form.on("Item Attribute", {});

View File

@@ -35,7 +35,6 @@
"purchase_expense_account",
"purchase_expense_contra_account",
"purchase_price_variance_account",
"manufacturing_variance_account",
"selling_defaults",
"column_break_sales",
"vf_selling_cost_center",
@@ -199,14 +198,6 @@
"options": "Account",
"show_description_on_click": 1
},
{
"description": "For Standard Cost items: the Manufacture/Repack consumed cost vs standard rate difference is booked here. Falls back to the Company's Default Manufacturing Variance Account.",
"fieldname": "manufacturing_variance_account",
"fieldtype": "Link",
"label": "Manufacturing Variance Account",
"options": "Account",
"show_description_on_click": 1
},
{
"fieldname": "column_break_purchase",
"fieldtype": "Column Break"
@@ -374,7 +365,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-01 11:48:07.853494",
"modified": "2026-06-26 10:05:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Default",

View File

@@ -28,7 +28,6 @@ class ItemDefault(Document):
expense_account: DF.Link | None
income_account: DF.Link | None
inventory_account_currency: DF.Link | None
manufacturing_variance_account: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -5,7 +5,7 @@
import frappe
from erpnext.stock.doctype.item_price.item_price import ItemPriceDuplicateItem
from erpnext.stock.get_item_details import get_price_list_rate_for
from erpnext.stock.get_item_details import ItemDetailsCtx, get_price_list_rate_for
from erpnext.tests.utils import ERPNextTestSuite
@@ -68,7 +68,7 @@ class TestItemPrice(ERPNextTestSuite):
# Check correct price at this quantity
doc = frappe.copy_doc(self.globalTestRecords["Item Price"][2])
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"customer": doc.customer,
@@ -84,7 +84,7 @@ class TestItemPrice(ERPNextTestSuite):
def test_price_with_no_qty(self):
# Check correct price when no quantity
doc = frappe.copy_doc(self.globalTestRecords["Item Price"][2])
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"customer": doc.customer,
@@ -100,7 +100,7 @@ class TestItemPrice(ERPNextTestSuite):
# Check correct price at first date
doc = frappe.copy_doc(self.globalTestRecords["Item Price"][2])
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"customer": "_Test Customer",
@@ -117,7 +117,7 @@ class TestItemPrice(ERPNextTestSuite):
# Check correct price at invalid date
doc = frappe.copy_doc(self.globalTestRecords["Item Price"][3])
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"qty": 7,
@@ -133,7 +133,7 @@ class TestItemPrice(ERPNextTestSuite):
# Check correct price when outside of the date
doc = frappe.copy_doc(self.globalTestRecords["Item Price"][4])
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"customer": "_Test Customer",
@@ -150,7 +150,7 @@ class TestItemPrice(ERPNextTestSuite):
# Check lowest price when no date provided
doc = frappe.copy_doc(self.globalTestRecords["Item Price"][1])
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"uom": "_Test UOM",
@@ -182,7 +182,7 @@ class TestItemPrice(ERPNextTestSuite):
doc.price_list_rate = 21
doc.insert()
ctx = frappe._dict(
ctx = ItemDetailsCtx(
{
"price_list": doc.price_list,
"uom": "_Test UOM",

View File

@@ -260,29 +260,6 @@ def get_purchase_price_variance_account(item_code, company):
return account
def get_manufacturing_variance_account(item_code, company):
"""Resolve the Manufacturing Variance account for a Standard Cost item: the per-company Item Default
override if set, otherwise the Company default. During Manufacture/Repack this account absorbs the
difference between the consumed (raw material + additional) cost and the finished good's standard rate."""
account = frappe.db.get_value(
"Item Default",
{"parent": item_code, "company": company},
"manufacturing_variance_account",
)
if not account:
account = frappe.get_cached_value("Company", company, "default_manufacturing_variance_account")
if not account:
frappe.throw(
_(
"Please set a Manufacturing Variance Account for Item {0} or a Default Manufacturing Variance Account in Company {1}."
).format(get_link_to_form("Item", item_code), frappe.bold(company))
)
return account
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_standard_cost_items(

View File

@@ -58,34 +58,10 @@ def ensure_ppv_account(company):
return account
def ensure_mfg_variance_account(company):
"""Ensure `company` has a Default Manufacturing Variance Account so Manufacture/Repack entries of
Standard Cost finished goods can book the consumed-cost-vs-standard difference."""
account = frappe.get_cached_value("Company", company, "default_manufacturing_variance_account")
if account:
return account
from erpnext.accounts.doctype.account.test_account import create_account
# Place it under the same group as the company's default expense account.
expense_account = frappe.get_cached_value("Company", company, "default_expense_account")
parent_account = frappe.db.get_value("Account", expense_account, "parent_account")
account = create_account(
account_name="Manufacturing Variance",
account_type="Expense Account",
parent_account=parent_account,
company=company,
account_currency=frappe.get_cached_value("Company", company, "default_currency"),
)
frappe.db.set_value("Company", company, "default_manufacturing_variance_account", account)
return account
class TestItemStandardCost(ERPNextTestSuite):
def setUp(self):
ensure_ppv_account(TEST_COMPANY)
ensure_ppv_account(PI_COMPANY)
ensure_mfg_variance_account(PI_COMPANY)
def test_only_for_standard_cost_items(self):
item = make_item(properties={"valuation_method": "FIFO", "is_stock_item": 1})
@@ -270,11 +246,9 @@ class TestItemStandardCost(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, se.submit)
def test_manufacturing_variance_books_to_variance_account(self):
def test_manufacturing_variance_books_to_stock_adjustment(self):
# RM standard 50, FG standard 200. Consuming 5 RM (250) to produce 1 FG (200) leaves a
# 50 (unfavorable) manufacturing variance, which must land in the company's Manufacturing
# Variance account, not the generic Stock Adjustment account.
mfg_variance = ensure_mfg_variance_account(PI_COMPANY)
# 50 manufacturing variance, which must land in the company's Stock Adjustment account.
rm = create_standard_cost_item()
fg = create_standard_cost_item()
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
@@ -301,94 +275,12 @@ class TestItemStandardCost(ERPNextTestSuite):
self.assertEqual(flt(fg_sle.valuation_rate), 200)
self.assertEqual(flt(fg_sle.stock_value_difference), 200)
def gl_net(account):
return flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
(se.name, account),
)[0][0]
)
# The 50 variance is reclassified to the Manufacturing Variance account...
self.assertEqual(gl_net(mfg_variance), 50)
# ...leaving the generic Stock Adjustment account untouched.
stock_adj = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
self.assertEqual(gl_net(stock_adj), 0)
def test_manufacturing_variance_includes_additional_costs(self):
# The variance is (full consumed cost - standard value), where consumed cost includes prorated
# additional costs. RM 5 x 50 = 250 plus a 30 additional cost = 280 consumed to make 1 FG valued
# at its standard 200 -> variance must be 280 - 200 = 80 (not 50).
mfg_variance = ensure_mfg_variance_account(PI_COMPANY)
additional_cost_account = "Expenses Included In Valuation - TCP1"
rm = create_standard_cost_item()
fg = create_standard_cost_item()
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
create_item_standard_cost(fg.name, rate=200, company=PI_COMPANY)
make_stock_entry(item_code=rm.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=50)
se = frappe.new_doc("Stock Entry")
se.purpose = "Repack"
se.stock_entry_type = "Repack"
se.company = PI_COMPANY
se.append("items", {"item_code": rm.name, "s_warehouse": PI_STORES, "qty": 5})
se.append("items", {"item_code": fg.name, "t_warehouse": PI_FG, "qty": 1, "is_finished_item": 1})
se.append(
"additional_costs",
{"expense_account": additional_cost_account, "description": "Freight", "amount": 30},
)
se.insert()
se.submit()
# FG is still valued at its own standard, regardless of the extra consumed cost.
fg_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name, "item_code": fg.name, "is_cancelled": 0},
["valuation_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(fg_sle.valuation_rate), 200)
self.assertEqual(flt(fg_sle.stock_value_difference), 200)
def gl_net(account):
return flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
(se.name, account),
)[0][0]
)
# Raw material (250) + additional cost (30) - standard value (200) = 80 to Manufacturing Variance.
self.assertEqual(gl_net(mfg_variance), 80)
# The additional cost is credited out of its source account (it flowed into the variance).
self.assertEqual(gl_net(additional_cost_account), -30)
def test_manufacturing_variance_account_required(self):
# Without a Manufacturing Variance account, submitting a Standard Cost Manufacture/Repack must fail.
previous = frappe.get_cached_value("Company", PI_COMPANY, "default_manufacturing_variance_account")
frappe.db.set_value("Company", PI_COMPANY, "default_manufacturing_variance_account", None)
frappe.clear_cache(doctype="Company")
try:
rm = create_standard_cost_item()
fg = create_standard_cost_item()
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
create_item_standard_cost(fg.name, rate=200, company=PI_COMPANY)
make_stock_entry(
item_code=rm.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=50
)
se = frappe.new_doc("Stock Entry")
se.purpose = "Repack"
se.stock_entry_type = "Repack"
se.company = PI_COMPANY
se.append("items", {"item_code": rm.name, "s_warehouse": PI_STORES, "qty": 5})
se.append("items", {"item_code": fg.name, "t_warehouse": PI_FG, "qty": 1, "is_finished_item": 1})
se.insert()
self.assertRaises(frappe.ValidationError, se.submit)
finally:
frappe.db.set_value("Company", PI_COMPANY, "default_manufacturing_variance_account", previous)
frappe.clear_cache(doctype="Company")
net = frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s",
(se.name, stock_adj),
)[0][0]
self.assertEqual(flt(net), 50)
def test_valuation_method_change_blocked_with_stock(self):
item = create_standard_cost_item()

View File

@@ -12,7 +12,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import flt
from erpnext.stock.get_item_details import get_item_details, get_price_list_rate
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details, get_price_list_rate
class PackedItem(Document):
@@ -326,8 +326,7 @@ def update_packed_item_with_pick_list_info(main_item_row, pi_row):
},
["warehouse", "batch_no", "serial_no"],
as_dict=True,
# name tiebreaker: split pick-list rows can tie on qty -> pick the same warehouse/batch/serial on both engines
order_by="qty desc, name asc",
order_by="qty desc",
)
if not pl_row:
@@ -344,7 +343,7 @@ def update_packed_item_price_data(pi_row, item_data, doc):
return
item_doc = frappe.get_cached_doc("Item", pi_row.item_code)
ctx = frappe._dict(pi_row.as_dict().copy())
ctx = ItemDetailsCtx(pi_row.as_dict().copy())
ctx.update(
{
"company": doc.get("company"),
@@ -442,7 +441,7 @@ def get_items_from_product_bundle(row: str | dict):
"""
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
row, items = frappe._dict(frappe.parse_json(row)), []
row, items = ItemDetailsCtx(frappe.parse_json(row)), []
if bundle_name := row.get("product_bundle"):
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)

View File

@@ -285,6 +285,9 @@ def create_stock_entry(pick_list: str | dict):
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
validate_item_locations(pick_list)
if stock_entry_exists(pick_list.get("name")):
return frappe.msgprint(_("Stock Entry has already been created against this Pick List"))
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.pick_list = pick_list.get("name")
stock_entry.purpose = pick_list.get("purpose")
@@ -298,9 +301,6 @@ def create_stock_entry(pick_list: str | dict):
else:
stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
if not stock_entry.get("items"):
return frappe.msgprint(_("All picked items have already been transferred against this Pick List"))
stock_entry.set_missing_values()
return stock_entry.as_dict()
@@ -366,8 +366,6 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
stock_entry.project = work_order.project
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
item.t_warehouse = wip_warehouse
@@ -379,8 +377,6 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
def update_stock_entry_based_on_material_request(pick_list, stock_entry):
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
target_warehouse = None
if location.material_request_item:
target_warehouse = frappe.get_value(
@@ -396,8 +392,6 @@ def update_stock_entry_based_on_material_request(pick_list, stock_entry):
def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
@@ -406,18 +400,11 @@ def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
return stock_entry
def get_pending_transfer_stock_qty(location):
"""Stock qty of this pick list row still to be moved into a Stock Entry."""
return flt(location.picked_qty) - flt(location.transferred_qty)
def update_common_item_properties(item, location):
pending_stock_qty = get_pending_transfer_stock_qty(location)
item.item_code = location.item_code
item.item_name = location.item_name
item.s_warehouse = location.warehouse
item.transfer_qty = pending_stock_qty
item.qty = flt(pending_stock_qty / (location.conversion_factor or 1), location.precision("qty"))
item.transfer_qty = location.picked_qty
item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
item.uom = location.uom
item.conversion_factor = location.conversion_factor
item.stock_uom = location.stock_uom
@@ -425,4 +412,3 @@ def update_common_item_properties(item, location):
item.serial_no = location.serial_no
item.batch_no = location.batch_no
item.material_request_item = location.material_request_item
item.pick_list_item = location.name

View File

@@ -190,7 +190,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nOpen\nPartly Delivered\nPartially Transferred\nCompleted\nCancelled",
"options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1,
"report_hide": 1,
@@ -278,7 +278,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-07-01 14:27:50.617011",
"modified": "2026-02-06 18:14:18.361039",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -71,9 +71,7 @@ class PickList(TransactionBase):
purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"]
scan_barcode: DF.Data | None
scan_mode: DF.Check
status: DF.Literal[
"Draft", "Open", "Partly Delivered", "Partially Transferred", "Completed", "Cancelled"
]
status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"]
work_order: DF.Link | None
# end: auto-generated types
@@ -419,34 +417,6 @@ class PickList(TransactionBase):
return stock_entry_exists(self.name)
def get_transfer_status(self):
"""Return the pick list's transfer progress based on how much of the picked qty has been
moved into submitted Stock Entries (tracked on Pick List Item.transferred_qty).
Only applies to purposes that move stock via Stock Entry; the Delivery purpose is tracked
via delivery_status instead. Returns "Completed", "Partially Transferred" or None."""
if self.purpose == "Delivery":
return None
total_picked = sum(flt(row.picked_qty) for row in self.locations)
if not total_picked:
return None
total_transferred = sum(flt(row.transferred_qty) for row in self.locations)
if total_transferred <= 0:
return None
if total_transferred >= total_picked:
return "Completed"
return "Partially Transferred"
def is_fully_transferred(self):
return self.get_transfer_status() == "Completed"
def is_partially_transferred(self):
return self.get_transfer_status() == "Partially Transferred"
def update_reference_qty(self):
packed_items = []
so_items = []

View File

@@ -7,7 +7,6 @@ frappe.listview_settings["Pick List"] = {
Draft: "red",
Open: "orange",
"Partly Delivered": "orange",
"Partially Transferred": "yellow",
Completed: "green",
Cancelled: "red",
};

View File

@@ -13,7 +13,6 @@ from erpnext.stock.doctype.pick_list.mapper import (
create_delivery,
create_delivery_note,
create_dn_for_pick_lists,
create_stock_entry,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
@@ -1222,64 +1221,6 @@ class TestPickList(ERPNextTestSuite):
pl.reload()
self.assertEqual(pl.status, "Cancelled")
def test_pick_list_partial_transfer_status(self):
"""Partial Stock Entries from a Pick List should track transferred_qty and drive the
Partially Transferred / Completed status, and allow further transfers for the remainder."""
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item = make_item(properties={"is_stock_item": 1}).name
source_warehouse = "_Test Warehouse - _TC"
target_warehouse = create_warehouse("_Test Transfer Target Warehouse")
make_stock_entry(item=item, to_warehouse=source_warehouse, qty=10)
pick_list = frappe.get_doc(
{
"doctype": "Pick List",
"company": "_Test Company",
"purpose": "Material Transfer",
"pick_manually": 1,
"locations": [
{
"item_code": item,
"qty": 10,
"stock_qty": 10,
"conversion_factor": 1,
"warehouse": source_warehouse,
"picked_qty": 10,
}
],
}
)
pick_list.submit()
self.assertEqual(pick_list.status, "Open")
# Transfer 4 of the 10 picked units.
se1 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(se1.items[0].qty, 10)
se1.items[0].qty = 4
se1.items[0].t_warehouse = target_warehouse
se1.submit()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
# The next Stock Entry should only offer the remaining 6 units.
se2 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(se2.items[0].qty, 6)
se2.items[0].t_warehouse = target_warehouse
se2.submit()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 10)
self.assertEqual(pick_list.status, "Completed")
# Cancelling the last entry rolls transferred_qty and status back.
se2.cancel()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
def test_pick_list_validation(self):
warehouse = "_Test Warehouse - _TC"
item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name

View File

@@ -22,7 +22,6 @@
"conversion_factor",
"stock_uom",
"delivered_qty",
"transferred_qty",
"available_quantity_section",
"actual_qty",
"column_break_kyek",
@@ -256,16 +255,6 @@
"read_only": 1,
"report_hide": 1
},
{
"default": "0",
"fieldname": "transferred_qty",
"fieldtype": "Float",
"label": "Transferred Qty (in Stock UOM)",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"fieldname": "available_quantity_section",
"fieldtype": "Section Break",
@@ -296,7 +285,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-01 14:27:50.617011",
"modified": "2026-03-17 16:25:10.358013",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",

View File

@@ -39,7 +39,6 @@ class PickListItem(Document):
stock_qty: DF.Float
stock_reserved_qty: DF.Float
stock_uom: DF.Link | None
transferred_qty: DF.Float
uom: DF.Link | None
use_serial_batch_fields: DF.Check
warehouse: DF.Link | None

View File

@@ -1909,92 +1909,6 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(query[0].value, 0)
def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
"""Internal-transfer PR's inward SLE must use DN.incoming_rate even when
PR.item.valuation_rate was wrong at submit, so divisional_loss does not
leak to COGS."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.stock_ledger import update_entries_after
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Drift From", company=company)
transit_warehouse = create_warehouse("_Test Drift Transit", company=company)
to_warehouse = create_warehouse("_Test Drift Receiver", company=company)
item_doc = create_item("Test Internal Drift Item")
make_purchase_receipt(
item_code=item_doc.name,
company=company,
posting_date=add_days(today(), -1),
warehouse=from_warehouse,
qty=10,
rate=100,
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=1,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
self.assertEqual(flt(dn.items[0].incoming_rate), 100.0)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.submit()
# Simulate the failure path
frappe.db.set_value(
"Purchase Receipt Item",
pr.items[0].name,
{"sales_incoming_rate": 0, "valuation_rate": 80},
)
inward_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"warehouse": to_warehouse,
"is_cancelled": 0,
},
["name", "item_code", "warehouse", "posting_date", "posting_time", "creation"],
as_dict=True,
)
frappe.db.set_value(
"Stock Ledger Entry",
inward_sle.name,
{"incoming_rate": 80, "stock_value_difference": 80},
)
update_entries_after(
{
"item_code": inward_sle.item_code,
"warehouse": inward_sle.warehouse,
"posting_date": inward_sle.posting_date,
"posting_time": inward_sle.posting_time,
"sle_id": inward_sle.name,
"creation": inward_sle.creation,
}
)
refreshed = frappe.db.get_value(
"Stock Ledger Entry",
inward_sle.name,
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(refreshed.incoming_rate), 100.0)
self.assertEqual(flt(refreshed.stock_value_difference), 100.0)
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice(
self,
):
@@ -2148,7 +2062,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(return_pi.docstatus, 1)
def test_disable_last_purchase_rate(self):
from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
item = make_item(
"_Test Disable Last Purchase Rate",
@@ -2163,7 +2077,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
item_code=item.name,
)
ctx = frappe._dict(pr.items[0].as_dict())
ctx = ItemDetailsCtx(pr.items[0].as_dict())
ctx.update(
{
"supplier": pr.supplier,

View File

@@ -1814,27 +1814,6 @@ class SerialandBatchBundle(Document):
self.set("entries", [])
def on_doctype_update():
if frappe.db.db_type == "postgres":
# Bundle-direct lookups (get_ledgers_from_serial_batch_bundle, get_picked_*) always filter
# `is_cancelled = 0` and scope by voucher_no or item_code+warehouse -- none of which the parent
# bundle is otherwise indexed on (only voucher_type/voucher_detail_no are). Partial indexes keep
# only the active bundles. Postgres-only (`where` is a no-op on MariaDB, and MariaDB's optimizer
# ignores partial predicates anyway).
frappe.db.add_index(
"Serial and Batch Bundle",
["voucher_no"],
index_name="sabb_active_voucher",
where="is_cancelled = 0",
)
frappe.db.add_index(
"Serial and Batch Bundle",
["item_code", "warehouse"],
index_name="sabb_active_item_wh",
where="is_cancelled = 0",
)
@frappe.whitelist()
def download_blank_csv_template(content: str | list):
csv_data = []

View File

@@ -38,85 +38,8 @@ class StockEntryGLComposer(BaseStockGLComposer):
self._append_lcv_gl_entries(gl_entries, inventory_account_map)
if doc.purpose in ("Repack", "Manufacture"):
self._append_manufacturing_variance_gl_entries(gl_entries)
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
def _append_manufacturing_variance_gl_entries(self, gl_entries: list) -> None:
"""For Standard Cost finished goods produced via Manufacture/Repack, stock is booked at the item's
standard rate, while the entry consumes raw-material (plus additional/landed) cost. The difference
is a manufacturing variance and is reclassified from the finished good's expense account to the
Manufacturing Variance account (mirrors Purchase Price Variance on a Purchase Receipt)."""
precision = self.get_debit_field_precision()
# Reuse the SLE map the base composer already fetched in compose() to avoid a second identical query.
sle_map = self._sle_map
for d in self.doc.get("items"):
variance = self._get_finished_good_variance(d, sle_map, precision)
if variance:
self._append_manufacturing_variance_pair(gl_entries, d, variance)
def _get_finished_good_variance(self, item, sle_map, precision) -> float:
"""Manufacturing variance for a Standard Cost finished good: the gap between the full computed
incoming cost (raw-material share + additional cost + LCV, i.e. ``amount``) and the standard value
actually booked into stock. Positive = consumed more than standard (unfavorable). 0 for anything
that is not a Standard Cost finished good."""
from erpnext.stock.utils import get_valuation_method
if not item.is_finished_item or not item.t_warehouse:
return 0.0
if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost":
return 0.0
# Value actually booked into stock for this finished good = qty * standard rate.
standard_value = sum(
flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0
)
return flt(flt(item.amount) - standard_value, precision)
def _append_manufacturing_variance_pair(self, gl_entries: list, item, variance: float) -> None:
"""Reclassify ``variance`` from the finished good's expense account to its Manufacturing Variance
account, restoring the expense account to the value it would carry without Standard Cost."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_manufacturing_variance_account,
)
doc = self.doc
variance_account = get_manufacturing_variance_account(item.item_code, doc.company)
cost_center = item.cost_center or frappe.get_cached_value("Company", doc.company, "cost_center")
remarks = doc.get("remarks") or _("Manufacturing Variance for {0}").format(item.item_code)
project = item.project or doc.get("project")
gl_entries.append(
self.get_gl_dict(
{
"account": variance_account,
"against": item.expense_account,
"cost_center": cost_center,
"remarks": remarks,
"debit": variance,
"project": project,
},
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": variance_account,
"cost_center": cost_center,
"remarks": remarks,
"debit": -1 * variance,
"project": project,
},
item=item,
)
)
def _build_additional_cost_per_item_account(
self, total_basic_amount: float, divide_based_on: float
) -> dict:

Some files were not shown because too many files have changed in this diff Show More