mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-02 13:16:55 +00:00
Compare commits
8 Commits
mergify/co
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7362ef043e | ||
|
|
57f7ed8d82 | ||
|
|
1ad1570e3e | ||
|
|
5d762c6e9e | ||
|
|
af3a08dd9e | ||
|
|
e52562f711 | ||
|
|
f5a2503206 | ||
|
|
ffec95263c |
10
.github/POSTGRES_COMPATIBILITY.md
vendored
10
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
7
.github/workflows/server-tests-postgres.yml
vendored
7
.github/workflows/server-tests-postgres.yml
vendored
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
2612
banking/yarn.lock
2612
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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, [])
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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'")
|
||||
@@ -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,
|
||||
|
||||
@@ -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}):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: ["", "1–10", "11–50", "51–200", "201–1,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -7,7 +7,6 @@ frappe.listview_settings["Pick List"] = {
|
||||
Draft: "red",
|
||||
Open: "orange",
|
||||
"Partly Delivered": "orange",
|
||||
"Partially Transferred": "yellow",
|
||||
Completed: "green",
|
||||
Cancelled: "red",
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user