Compare commits

..

2 Commits

Author SHA1 Message Date
ervishnucs
c49c1a18df chore: resolve backport conflicts for #58450
payment_request.py: take the incoming #58438 change in full. version-16-hotfix
lacked the subscription-plan population in make_payment_request() (never got
#57494), so the HEAD side of every hunk was empty -> keep the generic,
permission-checked get_subscription_details() and the subscription_plans
population / is_a_subscription derivation.

test_payment_request.py: kept at the version-16-hotfix state. The cherry-pick
conflict spanned ~1150 lines because the stable branch's test file is far
behind develop; taking "theirs" would have dragged in unrelated test history
from many other PRs. #58438's 4 new tests target TestPaymentRequestV2Gateway
and supplier-side subscription helpers that do not exist on version-16-hotfix,
so they are omitted from the backport.
2026-08-27 15:01:18 +05:30
Jatin3128
7b8d432032 fix(accounts): resolve subscription plans for any reference doctype in Payment Request (#58438)
fix(accounts): resolve subscription plans for any reference doctype and require read permission

get_subscription_details() was hardcoded to only resolve plans for
Sales Invoice, but is_a_subscription in make_payment_request() was set
for any reference doctype with a `subscription` field. Since Purchase
Invoice also has this field (supplier-side subscriptions), creating a
Payment Request against a subscription-linked Purchase Invoice set
is_a_subscription=1 with an empty subscription_plans table.

get_subscription_details() is also whitelisted with no permission
check, letting any logged-in user query which Subscription/plan/qty is
linked to an arbitrary Sales Invoice or Purchase Invoice.

Make plan resolution generic (guarded by Meta.has_field so doctypes
without a subscription field never hit a nonexistent column), derive
is_a_subscription from the resolved plans so the two can't disagree,
and add a frappe.has_permission read check before returning any data.

(cherry picked from commit 4cfa42921f)

# Conflicts:
#	erpnext/accounts/doctype/payment_request/payment_request.py
#	erpnext/accounts/doctype/payment_request/test_payment_request.py
2026-08-26 10:47:33 +00:00
150 changed files with 20479 additions and 151258 deletions

View File

@@ -1,54 +0,0 @@
name: Upload main.pot to Crowdin
on:
push:
branches:
- develop
- version-16-hotfix
paths:
- "erpnext/locale/main.pot"
workflow_dispatch:
concurrency:
group: crowdin-upload-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
upload-sources:
name: Upload sources from ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
- name: Checkout ${{ github.ref_name }}
uses: actions/checkout@v6
- name: Restore Crowdin cache
uses: actions/cache/restore@v6
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}
restore-keys: crowdin-${{ github.ref_name }}-
- name: Upload main.pot to Crowdin
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
config: crowdin.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
crowdin_branch_name: "[frappe.erpnext] ${{ github.ref_name }}"
upload_sources_args: "--cache"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Save Crowdin cache
uses: actions/cache/save@v6
if: always()
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}-${{ github.run_id }}

View File

@@ -13,7 +13,6 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
permissions:
contents: read

View File

@@ -13,7 +13,6 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"

View File

@@ -1,5 +1,14 @@
preserve_hierarchy: true
files:
- source: /erpnext/locale/main.pot
translation: /erpnext/locale/%two_letters_code%.po
pull_request_title: "fix: sync translations from crowdin"
pull_request_labels:
- translation
- skip-release-notes
pull_request_reviewers:
- barredterra # change to your GitHub username if you copied this file
commit_message: "fix: %language% translations"
append_commit_message: false
languages_mapping:
two_letters_code:
pt-BR: pt_BR

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.34.1"
__version__ = "16.26.2"
def get_default_company(user=None):

View File

@@ -234,10 +234,8 @@ frappe.ui.form.on("Dunning", {
dn: frm.doc.name,
},
callback: function (r) {
if (!r.exc) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
}
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
},

View File

@@ -31,7 +31,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
get_valid_api_method,
)
from erpnext.accounts.report.financial_statements import (
get_columns,
@@ -1165,12 +1164,10 @@ class RowProcessor:
def _process_api_row(self, row) -> RowData:
api_path = row.calculation_formula
method = get_valid_api_method(api_path)
# TODO
try:
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
if row.reverse_sign:
values = [-1 * v for v in values]

View File

@@ -163,7 +163,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "company",
fieldtype: "Link",
options: "Company",
label: __("Company"),
label: "Company",
reqd: 1,
default: frappe.defaults.get_user_default("Company"),
onchange: () => {
@@ -176,7 +176,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "view_type",
fieldtype: "Select",
options: ["Missing Accounts", "Filtered Accounts"],
label: __("View"),
label: "View",
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
reqd: 1,
onchange: () => {
@@ -192,10 +192,10 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tip",
fieldtype: "HTML",
label: __("Tip"),
label: "Tip",
options: `
<div class="alert alert-success" role="alert">
${__("Tip: Select report lines to view their accounts")}
Tip: Select report lines to view their accounts
</div>
`,
depends_on: has_selection ? "eval: false" : "eval: true",
@@ -203,7 +203,7 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tree_area",
fieldtype: "HTML",
label: __("Chart of Accounts"),
label: "Chart of Accounts",
read_only: 1,
depends_on: "eval: doc.company",
},
@@ -288,14 +288,14 @@ function update_formula_label(frm, data_source) {
if (!field) return;
const labels = {
"Account Data": __("Account Filter"),
"Custom API": __("API Method Path"),
"Account Data": "Account Filter",
"Custom API": "API Method Path",
};
grid.update_docfield_property(
"calculation_formula",
"label",
labels[data_source] || __("Calculation Formula")
labels[data_source] || "Calculation Formula"
);
}
@@ -370,7 +370,7 @@ function update_formula_description(frm, data_source) {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Custom API Setup</h5>
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
<p ${text_style}>Path to your custom method that returns financial data.</p>
<h6 ${subtitle_style}>Format:</h6>
<ul ${list_style}>
@@ -380,8 +380,7 @@ function update_formula_description(frm, data_source) {
<h6 ${subtitle_style}>Method Signature:</h6>
<div ${code_style}>
<!-- &#10; is used for line breaks since frappe.render replaces newlines with spaces -->
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"])&#10;def get_custom_data(filters, periods, row):&#10; # filters: dict — report filters (company, period, etc.)&#10; # periods: list[dict] — period definitions&#10; # row: dict — the current report row&#10;&#10; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br>&nbsp; # filters: dict — report filters (company, period, etc.) <br>&nbsp; # periods: list[dict] — period definitions <br>&nbsp; # row: dict — the current report row <br><br>&nbsp; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
</div>
<h6 ${subtitle_style}>Return Format:</h6>

View File

@@ -10,41 +10,18 @@ from enum import Enum
from typing import Any, ClassVar
import frappe
from frappe import _, is_whitelisted
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
from frappe.database.query import SQLFunctionParser
def get_valid_api_method(api_path: str):
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
method = frappe.get_attr(api_path)
is_whitelisted(method)
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
frappe.throw(
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
frappe.PermissionError,
title=_("Method Not Allowed"),
)
return method
def get_formula_field_label(data_source: str) -> str:
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
labels = {
"Account Data": _("Account Filter"),
"Custom API": _("API Method Path"),
}
return labels.get(data_source, _("Calculation Formula"))
@dataclass
class ValidationIssue:
"""Represents a single validation issue"""
message: str
row_idx: int | None = None
field: str | None = None
details: dict[str, Any] = None
def __post_init__(self):
@@ -52,9 +29,10 @@ class ValidationIssue:
self.details = {}
def __str__(self) -> str:
if self.row_idx:
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
return self.message
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
field_info = f"[{self.field}] " if self.field else ""
message = f"{prefix}{field_info}{self.message}"
return _(message)
@dataclass
@@ -156,9 +134,7 @@ class TemplateStructureValidator(Validator):
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
result.add_error(
ValidationIssue(
message=_(
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
).format(ref_code),
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
row_idx=row.idx,
)
)
@@ -167,7 +143,7 @@ class TemplateStructureValidator(Validator):
if ref_code in used_codes:
result.add_error(
ValidationIssue(
message=_("Duplicate line reference: '{0}'").format(ref_code),
message=f"Duplicate line reference: '{ref_code}'",
row_idx=row.idx,
)
)
@@ -183,7 +159,7 @@ class TemplateStructureValidator(Validator):
if row.data_source == "Account Data" and not row.balance_type:
result.add_error(
ValidationIssue(
message=_("Balance Type is required for Account Data"),
message="Balance Type is required for Account Data",
row_idx=row.idx,
)
)
@@ -193,9 +169,7 @@ class TemplateStructureValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for {1}").format(
get_formula_field_label(row.data_source), row.data_source
),
message=f"Formula is required for {row.data_source}",
row_idx=row.idx,
)
)
@@ -252,7 +226,7 @@ class DependencyValidator(Validator):
cycle = [*path[cycle_start:], node]
result.add_error(
ValidationIssue(
message=_("Circular dependency detected: {0}").format("".join(cycle)),
message=f"Circular dependency detected: {''.join(cycle)}",
)
)
return
@@ -284,7 +258,7 @@ class DependencyValidator(Validator):
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
message=f"Line References undefined in Formula: {', '.join(undefined)}",
row_idx=row_idx,
)
)
@@ -314,10 +288,9 @@ class CalculationFormulaValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for Calculated Amount").format(
get_formula_field_label(row.data_source)
),
message="Formula is required for Calculated Amount",
row_idx=row.idx,
field="Formula",
)
)
return result
@@ -329,7 +302,7 @@ class CalculationFormulaValidator(Validator):
if not self._are_parentheses_balanced(formula):
result.add_error(
ValidationIssue(
message=_("Formula has unbalanced parentheses"),
message="Formula has unbalanced parentheses",
row_idx=row.idx,
)
)
@@ -341,7 +314,7 @@ class CalculationFormulaValidator(Validator):
if row.reference_code and row.reference_code in refs:
result.add_error(
ValidationIssue(
message=_("Formula references itself ('{0}')").format(row.reference_code),
message=f"Formula references itself ('{row.reference_code}')",
row_idx=row.idx,
)
)
@@ -351,7 +324,7 @@ class CalculationFormulaValidator(Validator):
if undefined:
result.add_error(
ValidationIssue(
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
message=f"Formula references undefined codes: {', '.join(undefined)}",
row_idx=row.idx,
)
)
@@ -361,7 +334,7 @@ class CalculationFormulaValidator(Validator):
if eval_error:
result.add_error(
ValidationIssue(
message=_("Formula evaluation error: {0}").format(eval_error),
message=f"Formula evaluation error: {eval_error}",
row_idx=row.idx,
)
)
@@ -398,7 +371,7 @@ class CalculationFormulaValidator(Validator):
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
return f"Formula must return a numeric value, got {type(result).__name__}"
return None
except Exception as e:
@@ -421,10 +394,9 @@ class AccountFilterValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required for Account Data").format(
get_formula_field_label(row.data_source)
),
message="Account filter is required for Account Data",
row_idx=row.idx,
field="Formula",
)
)
return result
@@ -440,18 +412,18 @@ class AccountFilterValidator(Validator):
if error:
result.add_error(
ValidationIssue(
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
message=error,
row_idx=row.idx,
field="Account Filter",
)
)
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=_("{0}: Invalid JSON format: {1}").format(
get_formula_field_label(row.data_source), str(e)
),
message=f"Invalid JSON format: {e!s}",
row_idx=row.idx,
field="Account Filter",
)
)
@@ -466,36 +438,36 @@ class AccountFilterValidator(Validator):
# simple condition: [field, operator, value]
if isinstance(filter_config, list):
if len(filter_config) != 3:
return _("Filter must be [field, operator, value]")
return "Filter must be [field, operator, value]"
field, operator, value = filter_config
if not isinstance(field, str) or not isinstance(operator, str):
return _("Field and operator must be strings")
return "Field and operator must be strings"
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
if field not in account_fields:
return _("Field '{0}' is not a valid Account field").format(display)
return f"Field '{display}' is not a valid Account field"
if operator.casefold() not in OPERATOR_MAP:
return _("Invalid operator '{0}'").format(operator)
return f"Invalid operator '{operator}'"
if operator in ["in", "not in"] and not isinstance(value, list):
return _("Operator '{0}' requires a list value").format(operator)
return f"Operator '{operator}' requires a list value"
# logical condition: {"and": [condition1, condition2]}
elif isinstance(filter_config, dict):
if len(filter_config) != 1:
return _("Logical condition must have exactly one operator")
return "Logical condition must have exactly one operator"
op = next(iter(filter_config.keys())).lower()
if op not in ["and", "or"]:
return _("Logical operators must be 'and' or 'or'")
return "Logical operators must be 'and' or 'or'"
conditions = filter_config[next(iter(filter_config.keys()))]
if not isinstance(conditions, list) or len(conditions) < 1:
return _("Logical conditions need at least 1 sub-condition")
return "Logical conditions need at least 1 sub-condition"
# recursive
for condition in conditions:
@@ -503,7 +475,7 @@ class AccountFilterValidator(Validator):
if error:
return error
else:
return _("Filter must be a list or dict")
return "Filter must be a list or dict"
return None
@@ -539,31 +511,34 @@ class FormulaValidator(Validator):
if "." not in api_path:
result.add_error(
ValidationIssue(
message=_("{0} should be in format: app.module.method").format(
get_formula_field_label(row.data_source)
),
message="Custom API path should be in format: app.module.method",
row_idx=row.idx,
field="Formula",
)
)
return result
# Method exists?
try:
get_valid_api_method(api_path)
module_path, method_name = api_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, method_name):
result.add_error(
ValidationIssue(
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
row_idx=row.idx,
field="Formula",
)
)
except Exception as e:
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
get_formula_field_label(row.data_source), api_path
result.add_error(
ValidationIssue(
message=f"Could not validate API path: {e!s}",
row_idx=row.idx,
field="Formula",
)
else:
message = _("Could not validate {0}: {1}").format(
get_formula_field_label(row.data_source), str(e)
)
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
)
return result

View File

@@ -2,12 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FormulaValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -77,90 +72,3 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
)
return template
def not_whitelisted_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["POST"])
def whitelisted_post_only_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["GET"])
def whitelisted_get_method(**kwargs):
return [42.0]
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
"""Custom API rows must point to whitelisted methods that permit GET"""
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
def create_api_template(self, api_path):
template = self.create_test_template_with_rows(
[
{
"reference_code": "API001",
"display_name": "API Row",
"data_source": "Custom API",
"calculation_formula": api_path,
}
]
)
template.report_type = "Profit and Loss Statement"
return template
def test_get_valid_api_method(self):
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
def test_save_rejects_invalid_api_methods(self):
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
self.assertRaises(frappe.ValidationError, template.insert)
def test_save_allows_get_whitelisted_method(self):
template = self.create_api_template(self.WHITELISTED_GET)
template.insert()
template.delete()
def test_engine_rejects_invalid_api_methods(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
def test_engine_calls_valid_api_method(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
template = self.create_api_template(self.WHITELISTED_GET)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
row_data = processor._process_api_row(template.rows[0])
self.assertEqual(row_data.values, [42.0])
def test_validation_keeps_message_log_clean(self):
validator = FormulaValidator(frappe._dict(rows=[]))
message_count = len(frappe.local.message_log)
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
result = validator.validate(row)
self.assertFalse(result.is_valid)
self.assertEqual(len(frappe.local.message_log), message_count)

View File

@@ -1402,7 +1402,6 @@ def get_payment_entry_against_order(
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
):
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if flt(ref_doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
@@ -1448,8 +1447,6 @@ def get_payment_entry_against_invoice(
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
):
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if dt == "Sales Invoice":
party_type = "Customer"
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
@@ -1485,8 +1482,6 @@ def get_payment_entry_against_invoice(
def get_payment_entry(ref_doc, args):
frappe.has_permission("Journal Entry", ptype="create", throw=True)
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)

View File

@@ -56,9 +56,7 @@ class LedgerMerge(Document):
@frappe.whitelist()
def form_start_merge(docname):
lm_doc = frappe.get_doc("Ledger Merge", docname)
lm_doc.check_permission("write")
return lm_doc.start_merge()
return frappe.get_doc("Ledger Merge", docname).start_merge()
def start_merge(docname):

View File

@@ -297,9 +297,6 @@ def start_import(invoices):
invoice_number = d.invoice_number
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
# the outstanding amount is entered inclusive of tax, so taxes must not
# be added on top of it
doc.flags.dont_auto_add_taxes = True
doc.insert(set_name=invoice_number)
doc.submit()
if not frappe.in_test:

View File

@@ -4,11 +4,9 @@
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.tests.utils import ERPNextTestSuite
@@ -128,55 +126,6 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
)
def test_opening_invoice_creation_without_taxes(self):
company = "_Test Opening Invoice Company"
template = frappe.get_doc(
{
"doctype": "Sales Taxes and Charges Template",
"company": company,
"title": "_Test Opening Invoice Tax",
"taxes": [
{
"charge_type": "On Net Total",
"account_head": create_account(
account_name="_Test Opening Tax Account",
parent_account="Duties and Taxes - _TOIC",
account_type="Tax",
company=company,
),
"description": "Test taxes",
"rate": 9,
}
],
}
).insert()
# makes the template the default for the party, as it would be on a live site
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
tool = self.make_invoices(company=company, return_doc=True)
invoices = tool.make_invoices()
self.assertEqual(len(invoices), 2)
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
for invoice in invoices:
si = frappe.get_doc("Sales Invoice", invoice)
self.assertFalse(si.taxes)
self.assertEqual(si.grand_total, 200)
self.assertEqual(si.outstanding_amount, 200)
# the same invoice created outside the tool keeps the default taxes,
# since adding them there is the user's decision
si = frappe.get_doc(tool.get_invoices()[0])
si.flags.ignore_mandatory = True
si.insert()
self.assertTrue(si.taxes)
self.assertEqual(si.grand_total, 218)
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True

View File

@@ -2893,11 +2893,7 @@ def get_payment_entry(
reference_date=None,
created_from_payment_request=False,
):
frappe.has_permission("Payment Entry", ptype="create", throw=True)
doc = frappe.get_doc(dt, dn)
doc.check_permission()
over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))

View File

@@ -83,7 +83,6 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
def make_payment_records(name, supplier, mode_of_payment=None):
doc = frappe.get_doc("Payment Order", name)
doc.check_permission()
make_journal_entry(doc, supplier, mode_of_payment)

View File

@@ -92,7 +92,6 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) {
freeze: true,
callback: function (data) {
if (!data.exc) {
frm.clear_table("subscription_plans");
$.each(data.message || [], function (i, v) {
var d = frappe.model.add_child(
frm.doc,

View File

@@ -1188,7 +1188,6 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
@frappe.whitelist()
def get_available_payment_schedules(reference_doctype, reference_name):
ref_doc = frappe.get_doc(reference_doctype, reference_name)
ref_doc.check_permission()
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
return []

View File

@@ -261,15 +261,12 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
def get_invoices(start, end, pos_profile, user):
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("Sales Invoice", throw=True)
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
query = sales_inv_query
if invoice_doctype == "POS Invoice":
frappe.has_permission("POS Invoice", throw=True)
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
query = query + pos_inv_query

View File

@@ -280,40 +280,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
if not allowed_pos_profiles:
return {}
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
pos_profile = (
frappe.qb.from_(pf)
.inner_join(pfu)
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.where(pf.name.isin(allowed_pos_profiles))
.limit(page_len)
.offset(start)
.run()
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf, `tabPOS Profile User` pfu
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
)
if not pos_profile:
pos_profile = (
frappe.qb.from_(pf)
.left_join(pfu)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user.isnull() | (pfu.user == ""))
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
& (pf.name.isin(allowed_pos_profiles))
)
.run()
del args["user"]
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
on
pf.name = pfu.parent
where
ifnull(pfu.user, '') = ''
and pf.company = %(company)s
and pf.name like %(txt)s
and pf.disabled = 0""",
args,
)
return pos_profile

View File

@@ -12,9 +12,8 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
"hidden": 1,
"label": "Fieldname"
},
{
"fieldname": "field",
@@ -27,7 +26,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-31 20:41:12.000000",
"modified": "2025-07-29 18:08:40.323579",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",

View File

@@ -1,9 +1,40 @@
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
function is_valid_invoice_field(df) {
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
}
let search_fields_datatypes = [
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
];
let do_not_include_fields = [
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
];
frappe.ui.form.on("POS Settings", {
onload: function (frm) {
@@ -11,97 +42,74 @@ frappe.ui.form.on("POS Settings", {
frm.trigger("add_search_options");
},
invoice_type: function (frm) {
frm.trigger("get_invoice_fields");
},
get_invoice_fields: function (frm) {
const invoice_type = frm.doc.invoice_type;
if (!invoice_type) return;
frappe.model.with_doctype(invoice_type, () => {
// the invoice type can change again while the meta loads
if (frm.doc.invoice_type !== invoice_type) return;
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
frappe.model.with_doctype("POS Invoice", () => {
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
if (
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
["Button"].includes(d.fieldtype)
) {
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
} else {
return null;
}
});
frm.fields_dict.invoice_fields.grid.update_docfield_property(
"fieldname",
"options",
[""].concat(
fields.map((df) => {
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
})
)
[""].concat(fields)
);
frm.trigger("validate_invoice_fields");
});
},
validate_invoice_fields: function (frm) {
const valid_fieldnames = frappe
.get_doc("DocType", frm.doc.invoice_type)
.fields.filter(is_valid_invoice_field)
.map((df) => df.fieldname);
const invalid_fields = (frm.doc.invoice_fields || [])
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
.map((row) => `#${row.idx} ${row.fieldname}`);
if (!invalid_fields.length) return;
frappe.msgprint({
title: __("Invalid POS Fields"),
indicator: "orange",
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
frm.doc.invoice_type.bold(),
invalid_fields.join(", "),
]),
});
},
add_search_options: function (frm) {
frappe.call({
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
callback: ({ message }) => {
const fields = message || [];
frappe.model.with_doctype("Item", () => {
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (
search_fields_datatypes.includes(d.fieldtype) &&
!do_not_include_fields.includes(d.fieldname)
) {
return [d.label];
} else {
return null;
}
});
frm.searchable_item_fields = Object.fromEntries(
fields.map((df) => [df.option, df.fieldname])
);
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
"field",
"options",
[""].concat(fields.map((df) => df.option))
);
},
fields.unshift("");
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
});
},
});
frappe.ui.form.on("POS Search Fields", {
field: function (frm, doctype, name) {
const doc = frappe.get_doc(doctype, name);
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
return d;
} else {
return null;
}
})[0];
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
frm.refresh_field("pos_search_fields");
doc.fieldname = df.fieldname;
frm.refresh_field("fields");
},
});
frappe.ui.form.on("POS Field", {
fieldname: function (frm, doctype, name) {
const doc = frappe.get_doc(doctype, name);
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
if (!df) return;
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
return doc.fieldname == d.fieldname ? d : null;
})[0];
doc.label = df.label;
doc.reqd = df.reqd;
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("invoice_fields");
frm.refresh_field("fields");
},
});

View File

@@ -5,46 +5,8 @@ from collections import Counter
import frappe
from frappe import _
from frappe.model import no_value_fields
from frappe.model.document import Document
SEARCH_FIELD_TYPES = (
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
)
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
DO_NOT_INCLUDE_FIELDS = (
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
)
class POSSettings(Document):
# begin: auto-generated types
@@ -70,10 +32,17 @@ class POSSettings(Document):
if old_doc.invoice_type != self.invoice_type:
self.validate_invoice_type()
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_type(self):
pos_opening_entries_count = frappe.db.count(
@@ -86,94 +55,3 @@ class POSSettings(Document):
),
title=_("Invoice Document Type Selection Error"),
)
def validate_duplicate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_fields(self):
if not self.invoice_type:
return
meta = frappe.get_meta(self.invoice_type)
for field in self.invoice_fields:
df = meta.get_field(field.fieldname)
if not df or not is_valid_invoice_field(df):
frappe.throw(
title=_("Invalid POS Field"),
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
),
)
# read only in the form, so keep them in sync with the invoice
field.label = df.label
field.fieldtype = df.fieldtype
field.options = df.options
def validate_duplicate_pos_search_fields(self):
fieldnames = [field.fieldname for field in self.pos_search_fields]
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
for fieldname in duplicate_fieldnames:
frappe.throw(
title=_("Duplicate POS Search Fields"),
msg=_("'{0}' has been already added.").format(fieldname),
)
def validate_pos_search_fields(self):
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
for field in self.pos_search_fields:
df = searchable_fields.get(field.fieldname)
if not df:
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
field.idx, frappe.bold(field.fieldname or "")
),
)
if field.field != get_search_field_option(df):
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' does not match {2}.").format(
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
),
)
def is_valid_invoice_field(df):
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
def get_searchable_item_fields():
return [
df
for df in frappe.get_meta("Item").fields
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
]
def get_search_field_option(df):
# the fieldname keeps the option unique, two Item fields can share a label
return f"{df.label} ({df.fieldname})"
@frappe.whitelist()
def get_pos_search_field_options():
frappe.has_permission("POS Settings", throw=True)
return [
{"option": get_search_field_option(df), "fieldname": df.fieldname}
for df in get_searchable_item_fields()
]

View File

@@ -2,135 +2,8 @@
# See license.txt
import unittest
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
from erpnext.tests.utils import ERPNextTestSuite
class TestPOSSettings(ERPNextTestSuite):
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_invoice_field_is_validated_against_invoice_type(self):
# consolidated_invoice exists on POS Invoice only
self.settings.invoice_type = "POS Invoice"
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
self.settings.save()
self.settings.invoice_type = "Sales Invoice"
self.assertInvalid("is not a valid field of")
def test_field_common_to_both_invoice_types_is_allowed(self):
for invoice_type in ("POS Invoice", "Sales Invoice"):
self.settings.invoice_type = invoice_type
self.settings.invoice_fields = []
self.settings.append("invoice_fields", {"fieldname": "po_no"})
self.settings.save()
def test_unknown_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
self.assertInvalid("is not a valid field of")
def test_layout_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
self.assertInvalid("is not a valid field of")
def test_invoice_field_properties_are_set_from_the_invoice(self):
self.settings.append(
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
)
self.settings.save()
field = self.settings.invoice_fields[0]
self.assertEqual(field.label, "Customer")
self.assertEqual(field.fieldtype, "Link")
self.assertEqual(field.options, "Customer")
def test_searchable_item_field_is_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
def test_excluded_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_of_unsearchable_type_is_not_allowed(self):
# maintain stock is a Check field
self.settings.append(
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
)
self.assertInvalid("cannot be used to search items")
def test_unknown_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_without_a_fieldname_is_not_allowed(self):
# the form fills the fieldname in, it cannot be picked on its own
self.settings.append("pos_search_fields", {"field": "Description (description)"})
self.assertInvalid("cannot be used to search items")
def test_search_field_option_must_match_its_fieldname(self):
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
self.assertInvalid("does not match")
def test_bare_label_is_not_accepted_as_a_search_field(self):
# the stored option carries the fieldname, the patch backfills older rows
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
self.assertInvalid("does not match")
def test_duplicate_search_fields_are_not_allowed(self):
for _ in range(2):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.assertInvalid("has been already added")
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
row = self.settings.pos_search_fields[0].name
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
append_fieldname()
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_patch_leaves_an_already_migrated_search_field_alone(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
append_fieldname()
row = self.settings.pos_search_fields[0].name
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
self.settings.invoice_type = None
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.save()
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")
pass

View File

@@ -142,8 +142,6 @@ def start_pcv_processing(docname: str):
@frappe.whitelist()
def pause_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
@@ -159,8 +157,6 @@ def pause_pcv_processing(docname: str):
@frappe.whitelist()
def cancel_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
@@ -175,8 +171,6 @@ def cancel_pcv_processing(docname: str):
@frappe.whitelist()
def resume_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()

View File

@@ -42,7 +42,6 @@ from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
update_billed_amount_based_on_po,
)
@@ -2120,11 +2119,6 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
if isinstance(args, str):
args = json.loads(args)
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
def received_and_mapped_qty(obj):
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
@@ -2148,13 +2142,15 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
or {}
)
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
target.received_qty = pending_qty
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
obj.conversion_factor
)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -2184,9 +2180,7 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: (
abs(received_and_mapped_qty(doc)) < abs(doc.qty) and select_item(doc)
),
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -27,7 +27,6 @@ def start_payment_ledger_repost(docname=None):
"""
if docname:
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
repost_doc.check_permission("submit")
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
try:
for entry in repost_doc.repost_vouchers:

View File

@@ -9,7 +9,6 @@ from frappe.utils import cint, flt
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_currency_precision
def execute(filters=None):
@@ -44,7 +43,6 @@ def get_data(filters, show_party_name):
account_filter = get_accounts_with_children(filters.get("account"))
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
precision = get_currency_precision()
opening_balances = get_opening_balances(filters, account_filter)
balances_within_period = get_balances_within_period(filters, account_filter)
@@ -67,17 +65,14 @@ def get_data(filters, show_party_name):
# opening
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
# within period
debit, credit = balances_within_period.get(party.name, [0, 0])
debit, credit = flt(debit, precision), flt(credit, precision)
row.update({"debit": debit, "credit": credit})
# closing
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
row.update({"currency": company_currency})

View File

@@ -304,9 +304,6 @@ def get_payment_entries(filters, args):
pe.mode_of_payment,
pe.project,
pe.cost_center,
pe.payment_type,
pe.source_exchange_rate,
pe.target_exchange_rate,
)
.where(
(pe.docstatus == 1)
@@ -317,22 +314,6 @@ def get_payment_entries(filters, args):
)
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
payment_entries = query.run(as_dict=True)
if payment_entries:
ded = frappe.qb.DocType("Payment Entry Deduction")
deduction_totals = frappe._dict(
frappe.qb.from_(ded)
.select(ded.parent, Sum(ded.amount))
.where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
.groupby(ded.parent)
.run()
)
for d in payment_entries:
exchange_rate = (
d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
) or 1
d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
return payment_entries

View File

@@ -18,7 +18,6 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
from erpnext.accounts.party import get_party_account, get_party_account_currency
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
)
@@ -770,16 +769,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item")
def update_item(obj, target, source_parent):
received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
pending_qty = flt(obj.qty) - received_qty
target.qty = flt(obj.qty) if is_unit_price_row(obj) else pending_qty
target.stock_qty = pending_qty * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -811,9 +807,7 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
},
"postprocess": update_item,
"condition": lambda doc: (
doc.name not in mapped_qty_by_item
if is_unit_price_row(doc)
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty)
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
)
and doc.delivered_by_supplier != 1
and select_item(doc),
@@ -872,13 +866,9 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
)
return query.run(pluck="qty")[0] or 0
mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail")
def get_billed_and_mapped_qty(po_item_name):
return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0))
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
billed_qty = flt(get_billed_qty(obj.name))
target.qty = flt(obj.qty) - billed_qty
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -921,7 +911,6 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
or abs(doc.billed_amt) < abs(doc.amount)
or doc.qty > flt(get_billed_qty(doc.name))
)
and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name))
and select_item(doc),
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -559,7 +559,6 @@
"fieldname": "blanket_order_rate",
"fieldtype": "Currency",
"label": "Blanket Order Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -955,7 +954,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -10,7 +10,6 @@ from frappe.contacts.address_and_contact import (
load_address_and_contact,
)
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.utils import get_link_to_form
from erpnext.accounts.party import (
get_dashboard_info,
@@ -182,15 +181,10 @@ class Supplier(TransactionBase):
)
if internal_supplier:
internal_supplier_link = get_link_to_form("Supplier", internal_supplier)
frappe.throw(
_(
"Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal."
).format(
internal_supplier_link,
frappe.bold(self.represents_company),
),
title=_("Internal Supplier Already Exists"),
_("Internal Supplier for company {0} already exists").format(
frappe.bold(self.represents_company)
)
)
def create_primary_contact(self):

View File

@@ -11,7 +11,6 @@ from frappe.utils import flt, getdate, nowdate
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -247,8 +246,6 @@ def make_purchase_order(source_name, target_doc=None, args=None):
if isinstance(args, str):
args = json.loads(args)
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
def set_missing_values(source, target):
target.run_method("set_missing_values")
target.run_method("get_schedule_dates")
@@ -283,8 +280,7 @@ def make_purchase_order(source_name, target_doc=None, args=None):
["sales_order", "sales_order"],
],
"postprocess": update_item,
# no qty tracking between the two, so dedupe on the row reference alone
"condition": lambda d: d.name not in mapped_items and select_item(d),
"condition": select_item,
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -10,26 +10,6 @@ frappe.query_reports["Purchase Analytics"] = {
options: ["Supplier Group", "Supplier", "Item Group", "Item"],
default: "Supplier",
reqd: 1,
on_change: function () {
const entity_filter = frappe.query_report.get_filter("entity");
if (entity_filter) {
entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type"));
entity_filter.set_value([]);
entity_filter.refresh();
}
frappe.query_report.refresh();
},
},
{
fieldname: "entity",
label: __("Entity"),
fieldtype: "MultiSelectList",
get_data: function (txt) {
const tree_type = frappe.query_report.get_filter_value("tree_type");
if (!tree_type || tree_type === "Order Type") return [];
return frappe.db.get_link_options(tree_type, txt);
},
depends_on: "eval:doc.tree_type != 'Order Type'",
},
{
fieldname: "doc_type",
@@ -85,19 +65,6 @@ frappe.query_reports["Purchase Analytics"] = {
default: "Monthly",
reqd: 1,
},
{
fieldname: "curves",
label: __("Curves"),
fieldtype: "Select",
options: [
{ value: "select", label: __("Select") },
{ value: "all", label: __("All") },
{ value: "non-zeros", label: __("Non-Zeros") },
{ value: "total", label: __("Total Only") },
],
default: "select",
reqd: 1,
},
{
fieldname: "show_aggregate_value_from_subsidiary_companies",
label: __("Show Aggregate Value from Subsidiary Companies"),

View File

@@ -1,131 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
SUPPLIER = "_Test Supplier"
SUPPLIER_GROUP = "_Test Supplier Group"
# A historical window that ordinary test fixtures don't post into.
FROM_DATE = "2019-04-01"
TO_DATE = "2019-06-30"
class TestPurchaseAnalytics(ERPNextTestSuite):
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
def setUp(self):
frappe.set_user("Administrator")
def _filters(self, **overrides):
filters = {
"doc_type": "Purchase Order",
"value_quantity": "Value",
"range": "Monthly",
"company": COMPANY,
"from_date": FROM_DATE,
"to_date": TO_DATE,
}
filters.update(overrides)
return frappe._dict(filters)
def _rows(self, filters):
return {row["entity"]: row for row in execute(filters)[1]}
def make_po(self, qty=4, rate=250):
return create_purchase_order(
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
)
def test_supplier_entity_filter(self):
filters = self._filters(tree_type="Supplier", entity=[SUPPLIER], curves="all")
base_total = flt(self._rows(filters).get(SUPPLIER, {}).get("total", 0.0))
po = self.make_po()
columns, data, _message, chart, *_rest = execute(filters)
self.assertTrue(columns)
self.assertEqual({row["entity"] for row in data}, {SUPPLIER})
self.assertAlmostEqual(data[0]["total"] - base_total, flt(po.base_net_total), places=2)
supplier_name = frappe.db.get_value("Supplier", SUPPLIER, "supplier_name")
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {supplier_name})
def test_parent_supplier_group_filter_preserves_rollup(self):
self.make_po()
filters = self._filters(tree_type="Supplier Group")
unfiltered = self._rows(filters)
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=["All Supplier Groups"]))
self.assertEqual(set(filtered), {"All Supplier Groups"})
self.assertAlmostEqual(
filtered["All Supplier Groups"]["total"],
unfiltered["All Supplier Groups"]["total"],
places=2,
)
def test_supplier_group_entity_filter(self):
self.make_po()
unfiltered = self._rows(self._filters(tree_type="Supplier Group"))
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=[SUPPLIER_GROUP]))
self.assertEqual(set(filtered), {SUPPLIER_GROUP})
self.assertEqual(filtered[SUPPLIER_GROUP]["indent"], 0)
self.assertAlmostEqual(
filtered[SUPPLIER_GROUP]["total"], unfiltered[SUPPLIER_GROUP]["total"], places=2
)
def test_supplier_group_tree_rolls_up_to_root(self):
filters = self._filters(tree_type="Supplier Group")
base = self._rows(filters)
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
po = self.make_po(qty=4, rate=250)
rows = self._rows(filters)
# supplier is remapped to its group; the root sits at indent 0
self.assertIn(SUPPLIER_GROUP, rows)
self.assertIn("All Supplier Groups", rows)
self.assertNotIn(SUPPLIER, rows)
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
def test_item_group_tree_rolls_up_to_root(self):
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
filters = self._filters(tree_type="Item Group")
base = self._rows(filters)
base_group = flt(base.get(item_group, {}).get("total", 0.0))
po = self.make_po(qty=4, rate=250)
rows = self._rows(filters)
self.assertIn(item_group, rows)
self.assertIn("All Item Groups", rows)
# the raw item code must not leak as its own entity; the root sits at indent 0
self.assertNotIn("_Test Item", rows)
self.assertEqual(rows["All Item Groups"]["indent"], 0)
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
def test_supplier_group_by_quantity(self):
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
base = self._rows(filters)
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
po = self.make_po(qty=7, rate=100)
rows = self._rows(filters)
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
# the quantity must roll up to the root too, not just the leaf group
self.assertAlmostEqual(
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
)

View File

@@ -1305,11 +1305,6 @@ class AccountsController(TransactionBase):
if self.get("taxes") or self.get("is_pos"):
return
# set by the Opening Invoice Creation Tool, where the outstanding amount
# entered against a party is already inclusive of tax
if self.flags.dont_auto_add_taxes:
return
if frappe.get_single_value(
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
) and hasattr(self, "taxes_and_charges"):

View File

@@ -1,25 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict:
"""Return a map: {source row name: qty} of rows already mapped into the target document.
"Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows
are invisible to the pending-qty queries in the mappers, which only count submitted
documents -- so without this, selecting the same source document twice maps every row
again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in
the map even when its qty is 0, so mappers without qty tracking can dedupe on presence.
"""
if isinstance(target_doc, str):
target_doc = frappe.parse_json(target_doc)
qty_map = frappe._dict()
for row in (target_doc and target_doc.get("items")) or []:
if ref := row.get(ref_field):
qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field))
return qty_map

View File

@@ -639,7 +639,7 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
bo = frappe.qb.DocType("Blanket Order")
bo_item = frappe.qb.DocType("Blanket Order Item")
query = (
blanket_orders = (
frappe.qb.from_(bo)
.from_(bo_item)
.select(bo.name)
@@ -652,12 +652,10 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
& (bo.company == filters.get("company"))
& (bo.docstatus == 1)
)
.run()
)
if currency := filters.get("currency"):
query = query.where(bo.currency == currency)
return query.run()
return blanket_orders
@frappe.whitelist()

View File

@@ -75,7 +75,7 @@ SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
def is_inspection_exempt_secondary_row(doc, row) -> bool:
"""Whether the row is a secondary item on a document that produces secondary items."""
if not (row.get("secondary_item_type") or row.get("valuation_type")):
if not (row.get("secondary_item_type") or row.get("is_legacy_scrap_item")):
return False
if doc.doctype == "Stock Entry":
@@ -86,7 +86,9 @@ def is_inspection_exempt_secondary_row(doc, row) -> bool:
def stock_entry_row_requires_inspection(purpose, row):
"""Check if this Stock Entry row need a Quality Inspection."""
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("secondary_item_type") or row.get("valuation_type")):
if purpose in SECONDARY_ITEM_PURPOSES and (
row.get("secondary_item_type") or row.get("is_legacy_scrap_item")
):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)

View File

@@ -161,7 +161,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -1288,10 +1288,10 @@ class SubcontractingController(StockController):
total_amt = sum(
flt(item.amount)
for item in self.get("items")
if not item.get("secondary_item_type") and not item.get("valuation_type")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
)
for item in self.items:
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
@@ -1299,15 +1299,15 @@ class SubcontractingController(StockController):
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("secondary_item_type") and not item.get("valuation_type")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("secondary_item_type") and not item.get("valuation_type"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = 0
@frappe.whitelist()

View File

@@ -242,7 +242,7 @@ class SubcontractingInwardController:
for item in self.get("items")
if not item.is_finished_item
and not item.secondary_item_type
and not item.valuation_type
and not item.is_legacy_scrap_item
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items:
if (
item.is_finished_item or item.secondary_item_type or item.valuation_type
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.secondary_item_type and not item.valuation_type:
if not item.secondary_item_type and not item.is_legacy_scrap_item:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.secondary_item_type and not item.valuation_type
if not item.secondary_item_type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.secondary_item_type or item.valuation_type:
if item.secondary_item_type or item.is_legacy_scrap_item:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.secondary_item_type and not item.valuation_type
if not item.secondary_item_type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item"
)
qty_map[doctype][item.scio_detail] += (
@@ -791,7 +791,7 @@ class SubcontractingInwardController:
items = [
item
for item in self.items
if not item.is_finished_item and not item.secondary_item_type and not item.valuation_type
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
]
item_code_wh = frappe._dict(
{
@@ -894,7 +894,7 @@ class SubcontractingInwardController:
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
secondary_items_list = [
item for item in self.items if item.secondary_item_type or item.valuation_type
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
]
secondary_items = defaultdict(float)

View File

@@ -31,97 +31,6 @@ class TestMapper(ERPNextTestSuite):
src_items = item_list_1 + item_list_2 + item_list_3
self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items))
def test_get_items_from_is_idempotent(self):
"""Selecting the same source document twice must not duplicate rows in the target.
"Get Items From" hands the in-progress document back to the mapper as `target_doc`.
Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only)
cannot see them -- every mapper has to discount them explicitly.
"""
for label, make_source, method in self.idempotency_cases():
with self.subTest(label):
source = make_source()
target = frappe.get_attr(method)(source.name)
mapped_rows = len(target.items)
self.assertTrue(mapped_rows, f"{label}: nothing was mapped")
target = frappe.get_attr(method)(source.name, target)
self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated")
def idempotency_cases(self):
"""(label, source factory, mapper method) for every "Get Items From" button.
Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the
Quotation row, so there is nothing to deduplicate on.
"""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.material_request.test_material_request import (
make_material_request_for_items,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
self.load_test_records("Supplier Quotation")
def make_supplier_quotation():
return frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).submit()
return [
(
"Quotation -> Sales Order",
lambda: make_quotation(),
"erpnext.selling.doctype.quotation.quotation.make_sales_order",
),
(
"Sales Order -> Sales Invoice",
lambda: make_sales_order(),
"erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
),
(
"Sales Order -> Delivery Note",
lambda: make_sales_order(),
"erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
),
(
"Delivery Note -> Sales Invoice",
lambda: create_delivery_note(),
"erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
),
(
"Material Request -> Purchase Order",
lambda: make_material_request_for_items(["_Test Item"]),
"erpnext.stock.doctype.material_request.material_request.make_purchase_order",
),
(
"Supplier Quotation -> Purchase Order",
make_supplier_quotation,
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order",
),
(
"Purchase Order -> Purchase Receipt",
lambda: create_purchase_order(),
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt",
),
(
"Purchase Order -> Purchase Invoice",
lambda: create_purchase_order(),
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
),
(
"Purchase Receipt -> Purchase Invoice",
lambda: make_purchase_receipt(),
"erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
),
(
"Purchase Invoice -> Purchase Receipt",
lambda: make_purchase_invoice(),
"erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
),
]
def make_quotation(self, item_list, customer):
qtn = frappe.get_doc(
{

View File

@@ -39,7 +39,6 @@ def get_contract_template(template_name, doc):
doc = json.loads(doc)
contract_template = frappe.get_doc("Contract Template", template_name)
contract_template.check_permission()
contract_terms = None
if contract_template.contract_terms:

View File

@@ -29,18 +29,11 @@ class EmailCampaign(Document):
def validate(self):
self.set_date()
self.validate_recipient_email()
self.validate_email_campaign_already_exists()
self.update_status()
def validate_recipient_email(self):
if not self.recipient:
return
# checking if email is set for lead. Not checking for contact as email is a mandatory field for contact.
if self.email_campaign_for == "Lead":
self.validate_lead()
elif self.email_campaign_for == "Contact":
self.validate_contact()
self.validate_email_campaign_already_exists()
self.update_status()
def set_date(self):
if getdate(self.start_date) < getdate(today()):
@@ -63,13 +56,6 @@ class EmailCampaign(Document):
lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name")
frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name))
def validate_contact(self):
contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True)
if contact and not contact.email_id:
frappe.throw(
_("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name))
)
def validate_email_campaign_already_exists(self):
email_campaign_exists = frappe.db.exists(
"Email Campaign",

View File

@@ -1,84 +1,10 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, getdate, today
# import frappe
import unittest
from erpnext.tests.utils import ERPNextTestSuite
class TestEmailCampaign(ERPNextTestSuite):
"""Email Campaign derives its window from the linked Campaign schedule and
guards the start date and the recipient's email."""
def setUp(self):
frappe.set_user("Administrator")
def make_email_template(self):
name = "_Test EC Email Template"
if not frappe.db.exists("Email Template", name):
frappe.get_doc(
{"doctype": "Email Template", "name": name, "subject": "Test", "response": "Hello"}
).insert()
return name
def make_campaign(self, schedules):
campaign = frappe.new_doc("Campaign")
campaign.campaign_name = f"_Test EC Campaign {frappe.generate_hash(length=6)}"
for days in schedules:
campaign.append(
"campaign_schedules",
{"send_after_days": days, "email_template": self.make_email_template()},
)
return campaign.insert()
def make_email_campaign(self, campaign_name, start_date=None):
doc = frappe.new_doc("Email Campaign")
doc.campaign_name = campaign_name
doc.start_date = start_date or today()
return doc
def test_start_date_cannot_be_in_the_past(self):
doc = self.make_email_campaign("irrelevant", start_date=add_days(today(), -1))
self.assertRaises(frappe.ValidationError, doc.set_date)
def test_end_date_is_start_plus_max_send_after_days(self):
campaign = self.make_campaign(schedules=[0, 5])
doc = self.make_email_campaign(campaign.name)
doc.set_date()
self.assertEqual(getdate(doc.end_date), add_days(getdate(today()), 5))
def test_campaign_without_a_schedule_is_rejected(self):
campaign = self.make_campaign(schedules=[])
doc = self.make_email_campaign(campaign.name)
self.assertRaises(frappe.ValidationError, doc.set_date)
def test_lead_without_an_email_is_rejected(self):
lead = frappe.get_doc({"doctype": "Lead", "lead_name": "_Test Lead No Email"}).insert()
doc = frappe.new_doc("Email Campaign")
doc.email_campaign_for = "Lead"
doc.recipient = lead.name
self.assertRaises(frappe.ValidationError, doc.validate_lead)
def test_contact_without_an_email_is_rejected(self):
contact = frappe.get_doc({"doctype": "Contact", "first_name": "_Test Contact No Email"}).insert()
campaign = self.make_campaign(schedules=[0])
doc = self.make_email_campaign(campaign.name)
doc.email_campaign_for = "Contact"
doc.recipient = contact.name
self.assertRaisesRegex(frappe.ValidationError, "primary email ID", doc.insert)
def test_contact_with_an_email_is_accepted(self):
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "_Test Contact With Email",
"email_ids": [{"email_id": "_test_email_campaign@example.com", "is_primary": 1}],
}
).insert()
campaign = self.make_campaign(schedules=[0])
doc = self.make_email_campaign(campaign.name)
doc.email_campaign_for = "Contact"
doc.recipient = contact.name
doc.insert()
self.assertEqual(doc.status, "In Progress")
pass

View File

@@ -255,10 +255,7 @@ class CRMNote(Document):
notify_mentions(self.doctype, self.name, note)
@frappe.whitelist()
def edit_note(self, note: str, row_id: str):
# db_update() skips the write check that save() does in add_note/delete_note
self.check_permission("write")
def edit_note(self, note, row_id):
for d in self.notes:
if cstr(d.name) == row_id:
d.note = note

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Arabic\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Bulgarian\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-10 11:05\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Bosnian\n"
"MIME-Version: 1.0\n"
@@ -353,7 +353,7 @@ msgstr "'Trajanje Važenja Verifikacijskog Linka' mora biti između 15 i 60 minu
#: erpnext/accounts/doctype/bank_account/bank_account.py:79
msgid "'{0}' account is already used by {1}. Use another account."
msgstr "Račun '{0}' već koristi {1}. Koristi drugi račun."
msgstr "Račun '{0}' već koristi {1}. Koristite drugi račun."
#: erpnext/accounts/doctype/pos_settings/pos_settings.py:44
msgid "'{0}' has been already added."
@@ -9803,7 +9803,7 @@ msgstr "Nije moguće promijeniti atribute nakon transakcije zaliha. Napravi novi
#: erpnext/stock/doctype/item/item.py:1129
msgid "Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
msgstr "Nije moguće promijenuti artikal {0} iz serijaliziranog u neserijalizirani jer za njega postoji Serijski i Šaržni paket. Prvo obriši ili otkažite Serijski i Šaržni paket."
msgstr "Nije moguće promijenuti artikal {0} iz serijaliziranog u neserijalizirani jer za njega postoji Serijski i Šaržni paket. Prvo izbrišite ili otkažite Serijski i Šaržni paket."
#: erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py:74
msgid "Cannot change Reference Document Type."
@@ -16413,29 +16413,29 @@ msgstr "Sažetak Odgođenih Zadataka"
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Delete Accounting and Stock Ledger entries on deletion of transaction"
msgstr "Obriši unose Knjigovodstva i Registra Zaliha pri brisanju Transakcije"
msgstr "Izbriši unose Knjigovodstva i Registra Zaliha pri brisanju Transakcije"
#. Label of the delete_bin_data_status (Select) field in DocType 'Transaction
#. Deletion Record'
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
msgid "Delete Bins"
msgstr "Obriši Spremnike"
msgstr "Izbriši Spremnike"
#. Label of the delete_cancelled_entries (Check) field in DocType 'Repost
#. Accounting Ledger'
#: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json
msgid "Delete Cancelled Ledger Entries"
msgstr "Obriši poništene unose iz Registra"
msgstr "Izbrišite poništene unose iz Registra"
#. Label of a standard navbar item
#. Type: Action
#: erpnext/hooks.py erpnext/public/js/utils/demo.js:5
msgid "Delete Demo Data"
msgstr "Obriši Demo Podatke"
msgstr "Izbriši Demo Podatke"
#: erpnext/stock/doctype/inventory_dimension/inventory_dimension.js:65
msgid "Delete Dimension"
msgstr "Obriši Dimenziju"
msgstr "Izbriši Dimenziju"
#. Label of the delete_leads_and_addresses_status (Select) field in DocType
#. 'Transaction Deletion Record'
@@ -16447,14 +16447,14 @@ msgstr "Izriši Potencijalne Klijente i Adrese"
#. in DocType 'Appointment Booking Settings'
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json
msgid "Delete Permanently"
msgstr "Trajno Obriši"
msgstr "Trajno Izbriši"
#. Label of the delete_transactions_status (Select) field in DocType
#. 'Transaction Deletion Record'
#: erpnext/setup/doctype/company/company.js:179
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
msgid "Delete Transactions"
msgstr "Obriši Transakcije"
msgstr "Izbriši Transakcije"
#: erpnext/setup/doctype/company/company.js:249
msgid "Delete all the Transactions for {0}"
@@ -18347,7 +18347,7 @@ msgstr "Kopiraj Grupa Klijenta"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:190
msgid "Duplicate DocType"
msgstr "Dupliraj DocType"
msgstr "Dupliciraj DocType"
#: erpnext/setup/doctype/authorization_rule/authorization_rule.py:71
msgid "Duplicate Entry. Please check Authorization Rule {0}"
@@ -18363,7 +18363,7 @@ msgstr "Kopiraj Grupu Artikla"
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:102
msgid "Duplicate Item Under Same Parent"
msgstr "Dupliraj Artikal pod Istim Nadređenim"
msgstr "Dupliciraj Artikal pod Istim Nadređenim"
#: erpnext/manufacturing/doctype/workstation/workstation.py:80
#: erpnext/manufacturing/doctype/workstation_type/workstation_type.py:37
@@ -18372,7 +18372,7 @@ msgstr "Duplikat operativne komponente {0} je pronađen u operativnim komponenta
#: erpnext/accounts/doctype/pos_settings/pos_settings.py:44
msgid "Duplicate POS Fields"
msgstr "Dupliraj Kasa Polja"
msgstr "Dupliciraj Kasa Polja"
#: erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py:104
#: erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py:64
@@ -18409,7 +18409,7 @@ msgstr "Dupli unos naspram koda artikla {0} i proizvođača {1}"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:189
msgid "Duplicate entry: {0}{1}"
msgstr "Dupliraj unos: {0}{1}"
msgstr "Dupliciraj unos: {0}{1}"
#: erpnext/accounts/doctype/pos_profile/pos_profile.py:164
msgid "Duplicate item group found in the item group table"
@@ -20376,7 +20376,7 @@ msgstr "Brisanje pravila nije uspjelo."
#: erpnext/setup/demo.py:77
msgid "Failed to erase demo data, please delete the demo company manually."
msgstr "Brisanje demo podataka nije uspjelo, obriši demo poduzeće ručno."
msgstr "Brisanje demo podataka nije uspjelo, izbrišite demo poduzeće ručno."
#: erpnext/setup/setup_wizard/setup_wizard.py:17
#: erpnext/setup/setup_wizard/setup_wizard.py:18
@@ -21932,7 +21932,7 @@ msgstr "Zatvoreno"
#. Description of the 'Is Frozen' (Check) field in DocType 'Supplier'
#: erpnext/buying/doctype/supplier/supplier.json
msgid "Frozen suppliers block ledger entries until unfrozen. Use this to temporarily lock accounting activity without disabling the supplier."
msgstr "Zatvoreni dobavljači blokiraju unose u registar dok se ne otvore. Koristi ovo za privremeno zaključavanje knjigovodstvenih aktivnosti bez onemogućavanja dobavljača."
msgstr "Zatvoreni dobavljači blokiraju unose u registar dok se ne otvore. Koristite ovo za privremeno zaključavanje knjigovodstvenih aktivnosti bez onemogućavanja dobavljača."
#. Label of the fuel_type (Select) field in DocType 'Vehicle'
#: erpnext/setup/doctype/vehicle/vehicle.json
@@ -38051,7 +38051,7 @@ msgstr "Izradi Nabavni Račun ili Nabavnu Fakturu za artikal {0}"
#: erpnext/stock/doctype/item/item.py:706
msgid "Please delete Product Bundle {0}, before merging {1} into {2}"
msgstr "Obriši Artikal Paket {0}, prije spajanja {1} u {2}"
msgstr "Izbriši Artikal Paket {0}, prije spajanja {1} u {2}"
#: erpnext/assets/doctype/asset/depreciation.py:562
msgid "Please disable workflow temporarily for Journal Entry {0}"
@@ -41743,7 +41743,7 @@ msgstr "Pravilo Odlaganja već postoji za Artikal {0} u Skladištu {1}."
#. DocType 'Inventory Dimension'
#: erpnext/stock/doctype/inventory_dimension/inventory_dimension.json
msgid "Python expression evaluated on the server. Use doc.fieldname for the row and parent.fieldname for the parent document. When it evaluates to true the dimension becomes mandatory. Example: doc.t_warehouse and doc.qty > 0"
msgstr "Python izraz se računa na serveru. Koristi doc.fieldname za red i parent.fieldname za nadređeni dokument. Kada se računa kao istinito, dimenzija postaje obavezna. Primjer: doc.t_warehouse i doc.qty > 0"
msgstr "Python izraz se računa na serveru. Koristite doc.fieldname za red i parent.fieldname za nadređeni dokument. Kada se računa kao istinito, dimenzija postaje obavezna. Primjer: doc.t_warehouse i doc.qty > 0"
#: banking/src/components/features/BankReconciliation/BankRecDateFilter.tsx:41
msgid "Q1"
@@ -45902,7 +45902,7 @@ msgstr "Uloga obavještavanja o neuspjehu amortizacije"
#. 'Company'
#: erpnext/setup/doctype/company/company.json
msgid "Roles Allowed to Set and Edit Frozen Account Entries"
msgstr "Uloge kojima je dozvoljeno postavljanje i uređivanje unosa zamrznutih računa"
msgstr "Uloge kojima je dozvoljeno postavljanje i uređivanje unosa zatvorenih računa"
#. Label of the root (Link) field in DocType 'Bisect Nodes'
#: erpnext/accounts/doctype/bisect_nodes/bisect_nodes.json
@@ -46970,7 +46970,7 @@ msgstr "Red {0}: Dodijeljeni iznos {1} mora biti manji ili jednak preostalom izn
#: erpnext/stock/doctype/stock_entry/stock_entry.py:1663
msgid "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials."
msgstr "Red {0}: Kako je {1} omogućen, sirovine se ne mogu dodati u {2} unos. Koristi {3} unos za potrošnju sirovina."
msgstr "Red {0}: Kako je {1} omogućen, sirovine se ne mogu dodati u {2} unos. Koristite {3} unos za potrošnju sirovina."
#: erpnext/stock/doctype/material_request/material_request.py:1052
msgid "Row {0}: Bill of Materials not found for the Item {1}"
@@ -52263,7 +52263,7 @@ msgstr "Zaliha Primljena, ali nije Fakturisana"
#: erpnext/stock/workspace/stock/stock.json
#: erpnext/workspace_sidebar/stock.json
msgid "Stock Reconciliation"
msgstr "Lista Zaliha"
msgstr "Popis Zaliha"
#. Name of a DocType
#: erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
@@ -59396,7 +59396,7 @@ msgstr "Hitno"
#: erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.js:36
msgid "Use 'Repost in background' button to trigger background job. Job can only be triggered when document is in Queued or Failed status."
msgstr "Koristi dugme 'Ponovo knjiži u pozadini' da pokrenete posao u pozadini. Zadatak se može pokrenuti samo kada je dokument u stanju čekanja ili neuspješan."
msgstr "Koristite dugme 'Ponovo knjiži u pozadini' da pokrenete posao u pozadini. Zadatak se može pokrenuti samo kada je dokument u stanju čekanja ili neuspješan."
#. Description of the 'Advanced Filtering' (Check) field in DocType 'Financial
#. Report Row'
@@ -59472,7 +59472,7 @@ msgstr "Koristi Višeslojnu Sastavnicu"
#. DocType 'Global Defaults'
#: erpnext/setup/doctype/global_defaults/global_defaults.json
msgid "Use Posting Datetime for Naming Documents"
msgstr "Koristi datum i vrijeme registracije za Imenovanje Dokumenata"
msgstr "Koristite datum i vrijeme registracije za Imenovanje Dokumenata"
#. Label of the use_serial_batch_fields (Check) field in DocType 'Stock
#. Settings'
@@ -59533,7 +59533,7 @@ msgstr "Koristi Devizni Kurs Datuma Transakcije"
#: erpnext/projects/doctype/project/project.py:600
msgid "Use a name that is different from previous project name"
msgstr "Koristi naziv koji se razlikuje od naziva prethodnog projekta"
msgstr "Koristite naziv koji se razlikuje od naziva prethodnog projekta"
#. Label of the use_for_shopping_cart (Check) field in DocType 'Tax Rule'
#: erpnext/accounts/doctype/tax_rule/tax_rule.json
@@ -59550,13 +59550,13 @@ msgstr "Koristi stari Kontroler Proračuna"
#. 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Use legacy controller for Period Closing Voucher"
msgstr "Koristi stari kontroler za Verifikat Zatvaranje Perioda"
msgstr "Koristite stari kontroler za Verifikat Zatvaranje Perioda"
#. Label of the fallback_to_default_price_list (Check) field in DocType
#. 'Selling Settings'
#: erpnext/selling/doctype/selling_settings/selling_settings.json
msgid "Use prices from Default Price List as fallback"
msgstr "Koristi cjene iz Standard Cjenovnika kao Rezervnu Opciju"
msgstr "Koristite cjene iz Standard Cjenovnika kao Rezervnu Opciju"
#. Description of the 'Sales Order Date' (Date) field in DocType 'Sales Order
#. Item'
@@ -62485,7 +62485,7 @@ msgstr "po satu"
#: erpnext/stock/stock_ledger.py:2092
msgid "performing either one below:"
msgstr "izvodi bilo koje niže:"
msgstr "izvodi bilo koje dolje:"
#. Description of the 'Product Bundle Item' (Data) field in DocType 'Pick List
#. Item'
@@ -63005,7 +63005,7 @@ msgstr "{0} transakcija će biti uvezeno u sistem. Molimo Vas da pregledate deta
#: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:732
msgid "{0} units are reserved for Item {1} in Warehouse {2}, please un-reserve the same to {3} the Stock Reconciliation."
msgstr "{0} jedinica je rezervisano za artikal {1} u Skladištu {2}, poništi rezervaciju iste za {3} Listu Zaliha."
msgstr "{0} jedinica je rezervisano za artikal {1} u Skladištu {2}, poništi rezervaciju iste za {3} Popis Zaliha."
#: erpnext/stock/doctype/pick_list/pick_list.py:1120
msgid "{0} units of Item {1} is not available in any of the warehouses."
@@ -63297,7 +63297,7 @@ msgstr "{0}: ukloni nevažeću vrijednost(i) {1}"
#: erpnext/stock/doctype/item/item.js:897
msgid "{0}: select the typed value {1} from the list or clear it"
msgstr "{0}: odaberi unesenu vrijednost {1} s liste ili je obriši"
msgstr "{0}: odaberi unesenu vrijednost {1} s liste ili je obrišite"
#: erpnext/controllers/accounts_controller.py:567
msgid "{0}: {1} does not belong to the Company: {2}"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Czech\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Danish\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: German\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:44\n"
"PO-Revision-Date: 2026-08-09 11:03\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Esperanto\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: French\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:03\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Hindi\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-10 11:05\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Croatian\n"
"MIME-Version: 1.0\n"

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Indonesian\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Italian\n"
"MIME-Version: 1.0\n"

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-10 11:05\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Korean\n"
"MIME-Version: 1.0\n"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:03\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Burmese\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:03\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Norwegian Bokmal\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Dutch\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Polish\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Portuguese\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Portuguese, Brazilian\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:01\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Romanian\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
@@ -12892,13 +12892,13 @@ msgstr "Расходы"
#. Label of the cost_allocation (Currency) field in DocType 'BOM'
#: erpnext/manufacturing/doctype/bom/bom.json
msgid "Cost Allocation"
msgstr "Распределение расходов"
msgstr ""
#. Label of the cost_allocation_per (Percent) field in DocType 'BOM Secondary
#. Item'
#: erpnext/manufacturing/doctype/bom_secondary_item/bom_secondary_item.json
msgid "Cost Allocation %"
msgstr "Распределение расходов %"
msgstr ""
#. Label of the cost_allocation__process_loss_section (Section Break) field in
#. DocType 'BOM'
@@ -13453,7 +13453,7 @@ msgstr "Создать клиента"
#: erpnext/selling/onboarding_step/create_delivery_note/create_delivery_note.json
#: erpnext/stock/onboarding_step/create_delivery_note/create_delivery_note.json
msgid "Create Delivery Note"
msgstr "Создать транспортную накладную"
msgstr "Создать транспортную накладную"
#: erpnext/stock/doctype/delivery_note/delivery_note_list.js:63
msgid "Create Delivery Trip"
@@ -22521,7 +22521,7 @@ msgstr "Получить комплектующие изделия"
#: erpnext/buying/doctype/supplier/supplier.js:151
msgid "Get Supplier Group Details"
msgstr "Получить данные о группе поставщиков"
msgstr "Получить данные о группе поставщиков"
#: erpnext/buying/doctype/request_for_quotation/request_for_quotation.js:461
#: erpnext/buying/doctype/request_for_quotation/request_for_quotation.js:481
@@ -26685,7 +26685,7 @@ msgstr "После объединения позиций может потреб
#: erpnext/public/js/controllers/transaction.js:2569
msgid "It is needed to fetch Item Details."
msgstr "Это необходимо для отображения подробностей продукта."
msgstr "Р­СРѕ необходимо для отображения подробностей продукта."
#: banking/src/components/features/BankReconciliation/BankBalance.tsx:79
msgid "It takes into account all the transactions that have been posted and subtracts the transactions that have not cleared yet."
@@ -29337,7 +29337,7 @@ msgstr "Связь с клиентом не удалась. Пожалуйста
#: erpnext/selling/doctype/customer/customer.js:280
msgid "Linking to Supplier Failed. Please try again."
msgstr "Ссылка на поставщика не удалась. Попробуйте еще раз."
msgstr "Ссылка на поставщика не удалась. Попробуйте еще раз."
#: erpnext/accounts/report/financial_ratios/financial_ratios.js:55
#: erpnext/accounts/report/financial_ratios/financial_ratios.py:152
@@ -31799,7 +31799,7 @@ msgstr ""
#: erpnext/selling/doctype/customer/customer.py:454
msgid "Multiple Loyalty Programs found for Customer {}. Please select manually."
msgstr "Найдено несколько программ лояльности для клиента {}. Выберите вручную."
msgstr "Найдено несколько программ лояльности для клиента {}. Выберите вручную."
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:1247
msgid "Multiple POS Opening Entry"
@@ -31807,7 +31807,7 @@ msgstr "Несколько записей открытия POS"
#: erpnext/accounts/doctype/pricing_rule/utils.py:348
msgid "Multiple Price Rules exists with same criteria, please resolve conflict by assigning priority. Price Rules: {0}"
msgstr "Несколько Цена Правила существует с теми же критериями, пожалуйста разрешить конфликт путем присвоения приоритета. Цена Правила: {0}"
msgstr "Несколько Цена Правила существует с теми же критериями, пожалуйста разрешить конфликт путем присвоения приоритета. Цена Правила: {0}"
#. Option for the 'Loyalty Program Type' (Select) field in DocType 'Loyalty
#. Program'
@@ -37908,7 +37908,7 @@ msgstr ""
#: erpnext/public/js/utils/serial_no_batch_selector.js:662
msgid "Please add atleast one Serial No / Batch No"
msgstr "Пожалуйста, добавьте хотя бы один серийный номер/номер партии"
msgstr "Пожалуйста, добавьте хотя бы один серийный номер/номер партии"
#: erpnext/crm/doctype/crm_settings/crm_settings.py:53
msgid "Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
@@ -38496,7 +38496,7 @@ msgstr "Выберите счёт учёта товарных запасов"
#: erpnext/stock/doctype/stock_entry/stock_entry.py:2036
msgid "Please select Subcontracting Order instead of Purchase Order {0}"
msgstr "Пожалуйста, выберите «Заказ на субподряд» вместо «Заказ на закупку» {0}"
msgstr "Пожалуйста, выберите «Заказ на субподряд» вместо «Заказ на закупку» {0}"
#: erpnext/controllers/accounts_controller.py:2857
msgid "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}"
@@ -38602,7 +38602,7 @@ msgstr "Пожалуйста, выберите поставщика для по
#: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py:142
msgid "Please select a valid Purchase Order that has Service Items."
msgstr "Пожалуйста, выберите действительный заказ на покупку, содержащий услуги."
msgstr "Пожалуйста, выберите действительный заказ на покупку, содержащий услуги."
#: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py:139
msgid "Please select a valid Purchase Order that is configured for Subcontracting."
@@ -38852,7 +38852,7 @@ msgstr "Укажите компанию"
#: erpnext/assets/doctype/asset/asset.py:378
msgid "Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {}"
msgstr "Пожалуйста, установите Центр затрат для Актива или установите Центр затрат на амортизацию Актива для Компании {}"
msgstr "Пожалуйста, установите Центр затрат для Актива или установите Центр затрат на амортизацию Актива для Компании {}"
#: erpnext/projects/doctype/project/project.py:768
msgid "Please set a default Holiday List for Company {0}"
@@ -38899,17 +38899,17 @@ msgstr "Пожалуйста, установите Cash умолчанию ил
#: erpnext/accounts/doctype/pos_profile/pos_profile.py:197
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:3097
msgid "Please set default Cash or Bank account in Mode of Payment {}"
msgstr "Установите по умолчанию наличный или банковский счет в режиме оплаты {}"
msgstr "Установите по умолчанию наличный или банковский счет в режиме оплаты {}"
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:96
#: erpnext/accounts/doctype/pos_profile/pos_profile.py:199
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:3099
msgid "Please set default Cash or Bank account in Mode of Payments {}"
msgstr "Установите по умолчанию наличный или банковский счет в режиме оплаты {}"
msgstr "Установите по умолчанию наличный или банковский счет в режиме оплаты {}"
#: erpnext/accounts/utils.py:2522
msgid "Please set default Exchange Gain/Loss Account in Company {}"
msgstr "Пожалуйста, установите по умолчанию счет учета прибыли/убытка от курсовых разниц в компании {}"
msgstr "Пожалуйста, установите РїРѕ умолчанию счет учета прибыли/убытка РѕС РєСѓСЂСЃРѕРІС‹С… разниц РІ компании {}"
#: erpnext/assets/doctype/asset_repair/asset_repair.py:389
msgid "Please set default Expense Account in Company {0}"
@@ -39352,7 +39352,7 @@ msgstr "Время публикации"
#: erpnext/stock/doctype/stock_entry/stock_entry.py:2923
msgid "Posting date and posting time is mandatory"
msgstr "Дата публикации и размещения время является обязательным"
msgstr "Дата публикации и размещения время является обязательным"
#: banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx:901
msgid "Posting date does not match the selected transaction"
@@ -41466,7 +41466,7 @@ msgstr "Заказы на закупку для получения"
#: erpnext/controllers/accounts_controller.py:2048
msgid "Purchase Orders {0} are un-linked"
msgstr "Заказы на покупку {0} разъединены"
msgstr "Заказы на покупку {0} разъединены"
#: erpnext/stock/report/item_prices/item_prices.py:59
msgid "Purchase Price List"
@@ -44208,7 +44208,7 @@ msgstr "Склад брака"
#: erpnext/public/js/utils/serial_no_batch_selector.js:670
msgid "Rejected Warehouse and Accepted Warehouse cannot be same."
msgstr "Склад отклоненных товаров и склад принятых товаров не могут быть одним и тем же."
msgstr "Склад отклоненных товаров и склад принятых товаров не могут быть одним и тем же."
#: erpnext/buying/doctype/purchase_order/purchase_order_dashboard.py:23
#: erpnext/buying/doctype/supplier_quotation/supplier_quotation_dashboard.py:14
@@ -46864,7 +46864,7 @@ msgstr ""
#: erpnext/assets/doctype/asset/asset.py:425
msgid "Row #{}: Finance Book should not be empty since you're using multiple."
msgstr "Строка #{}: Финансовая книга не может быть пустой, так как используется несколько книг."
msgstr "Строка #{}: Финансовая книга не может быть пустой, так как используется несколько книг."
#: erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py:92
msgid "Row #{}: POS Invoice {} has been {}"
@@ -46888,7 +46888,7 @@ msgstr "Строка №{}: Назначьте задачу участнику."
#: erpnext/assets/doctype/asset/asset.py:417
msgid "Row #{}: Please use a different Finance Book."
msgstr "Строка #{}: Используйте другую финансовую книгу."
msgstr "Строка #{}: Используйте другую финансовую книгу."
#: erpnext/accounts/doctype/pos_invoice/pos_invoice.py:524
msgid "Row #{}: Serial No {} cannot be returned since it was not transacted in original invoice {}"
@@ -46900,7 +46900,7 @@ msgstr "Строка #{}: Исходный счёт {} возвратного с
#: erpnext/accounts/doctype/pos_invoice/pos_invoice.py:497
msgid "Row #{}: You cannot add positive quantities in a return invoice. Please remove item {} to complete the return."
msgstr "Строка #{}: Вы не можете добавлять положительные количества в счет-фактуру возврата. Пожалуйста, удалите элемент {}, чтобы завершить возврат."
msgstr "Строка #{}: РС РЅРµ можете добавлять положительные количества РІ счет-фактуру возврата. Пожалуйста, удалите элемент {}, чтобы завершить возврат."
#: erpnext/stock/doctype/pick_list/pick_list.py:239
msgid "Row #{}: item {} has been picked already."
@@ -46917,7 +46917,7 @@ msgstr ""
#: erpnext/stock/doctype/item/item.py:1537
msgid "Row #{}: {} {} doesn't belong to Company {}. Please select valid {}."
msgstr "Строка №{}: {} {} не принадлежит компании {}. Выберите допустимый {}."
msgstr "Строка №{}: {} {} не принадлежит компании {}. Выберите допустимый {}."
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py:450
msgid "Row No {0}: Warehouse is required. Please set a Default Warehouse for Item {1} and Company {2}"
@@ -47147,7 +47147,7 @@ msgstr "Строка {0}: Выберите активную спецификац
#: erpnext/controllers/subcontracting_controller.py:224
msgid "Row {0}: Please select an valid BOM for Item {1}."
msgstr "Строка {0}: Выберите действительную спецификацию для товара {1}."
msgstr "Строка {0}: Выберите действительную спецификацию для товара {1}."
#: erpnext/regional/italy/utils.py:290
msgid "Row {0}: Please set at Tax Exemption Reason in Sales Taxes and Charges"
@@ -47322,7 +47322,7 @@ msgstr "В строках {0} указан тип ссылки 'Платежна
#: erpnext/controllers/accounts_controller.py:307
msgid "Rows: {0} in {1} section are Invalid. Reference Name should point to a valid Payment Entry or Journal Entry."
msgstr "Строки: {0} в разделе {1} недействительны. Имя ссылки должно указывать на действительную запись платежа или запись журнала."
msgstr "Строки: {0} в разделе {1} недействительны. Имя ссылки должно указывать на действительную запись платежа или запись журнала."
#. Label of the rule_applied (Check) field in DocType 'Pricing Rule Detail'
#: erpnext/accounts/doctype/pricing_rule_detail/pricing_rule_detail.json
@@ -51972,7 +51972,7 @@ msgstr "Подробности о запасах"
#: erpnext/stock/doctype/stock_entry/stock_entry.py:1210
msgid "Stock Entries already created for Work Order {0}: {1}"
msgstr "Записи по запасам уже созданы для заказа на работу {0}: {1}"
msgstr "Записи по запасам уже созданы для заказа на работу {0}: {1}"
#. Label of the stock_entry (Link) field in DocType 'Journal Entry'
#. Label of a Link in the Manufacturing Workspace
@@ -52036,7 +52036,7 @@ msgstr "Создана складская запись {0}"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1614
msgid "Stock Entry {0} has created"
msgstr "Запись по запасам {0} была создана"
msgstr "Запись по запасам {0} была создана"
#: erpnext/accounts/doctype/journal_entry/journal_entry.py:1335
msgid "Stock Entry {0} is not submitted"
@@ -52679,7 +52679,7 @@ msgstr ""
#: erpnext/selling/page/point_of_sale/pos_controller.js:826
msgid "Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2} {3}."
msgstr "Количество на складе недостаточно для Код товара: {0} на складе {1}. Доступное количество {2} {3}."
msgstr "Количество на складе недостаточно для Код товара: {0} на складе {1}. Доступное количество {2} {3}."
#: erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py:256
msgid "Stock transactions before {0} are frozen"
@@ -55332,7 +55332,7 @@ msgstr "Список выбора, имеющий записи резервир
#: erpnext/stock/doctype/stock_entry/stock_entry.py:3208
msgid "The Process Loss Qty has reset as per job cards Process Loss Qty"
msgstr "Количество потерь в процессе было сброшено в соответствии с количеством потерь в карточках рабочих заданий"
msgstr "Количество потерь в процессе было сброшено в соответствии с количеством потерь в карточках рабочих заданий"
#: erpnext/setup/doctype/sales_person/sales_person.py:102
msgid "The Sales Person is linked with {0}"
@@ -55720,7 +55720,7 @@ msgstr "Акций не существует с {0}"
#: erpnext/stock/stock_ledger.py:866
msgid "The stock for the item {0} in the {1} warehouse was negative on the {2}. You should create a positive entry {3} before the date {4} and time {5} to post the correct valuation rate. For more details, please read the <a href='https://docs.erpnext.com/docs/user/manual/en/stock-adjustment-cogs-with-negative-stock'>documentation<a>."
msgstr "Запас товара {0} на складе {1} был отрицательным на {2}. Вам нужно создать положительную запись {3} до даты {4} и времени {5}, чтобы корректно зафиксировать стоимость. Для получения подробной информации, пожалуйста, прочитайте <a href='https://docs.erpnext.com/docs/user/manual/en/stock-adjustment-cogs-with-negative-stock'>документацию<a>."
msgstr "Запас товара {0} на складе {1} был отрицательным на {2}. Вам нужно создать положительную запись {3} до даты {4} и времени {5}, чтобы корректно зафиксировать стоимость. Для получения подробной информации, пожалуйста, прочитайте <a href='https://docs.erpnext.com/docs/user/manual/en/stock-adjustment-cogs-with-negative-stock'>документацию<a>."
#: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:742
msgid "The stock has been reserved for the following Items and Warehouses, un-reserve the same to {0} the Stock Reconciliation: <br /><br /> {1}"
@@ -55754,7 +55754,7 @@ msgstr "Задача поставлена в очередь как фоново
#: erpnext/stock/doctype/material_request/material_request.py:400
msgid "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than allowed requested quantity {2} for Item {3}"
msgstr "Общее количество выпуска/передачи {0} в запросе на материалы {1} не может быть больше, чем допустимое запрошенное количество {2} для товара {3}"
msgstr "Общее количество выпуска/передачи {0} в запросе на материалы {1} не может быть больше, чем допустимое запрошенное количество {2} для товара {3}"
#: erpnext/stock/doctype/material_request/material_request.py:407
msgid "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than requested quantity {2} for Item {3}"
@@ -55883,7 +55883,7 @@ msgstr ""
#: erpnext/stock/doctype/item/item.js:1296
msgid "There are two options to maintain valuation of stock. FIFO (first in - first out) and Moving Average. To understand this topic in detail please visit <a href='https://docs.frappe.io/erpnext/user/manual/en/calculation-of-valuation-rate-in-fifo-and-moving-average' target='_blank'>Item Valuation, FIFO and Moving Average.</a>"
msgstr "Существует два варианта ведения оценки запасов. FIFO (первым пришел - первым ушел) и скользящая средняя. Чтобы подробно разобраться в этой теме, посетите <a href='https://docs.frappe.io/erpnext/user/manual/en/calculation-of-valuation-rate-in-fifo-and-moving-average' target='_blank'>Оценка товара, FIFO и скользящая средняя.</a>"
msgstr "Существует два варианта ведения оценки запасов. FIFO (первым пришел - первым ушел) и скользящая средняя. Чтобы подробно разобраться в этой теме, посетите <a href='https://docs.frappe.io/erpnext/user/manual/en/calculation-of-valuation-rate-in-fifo-and-moving-average' target='_blank'>Оценка товара, FIFO и скользящая средняя.</a>"
#: banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx:982
msgid "There are {0} unreconciled transactions before {1}."
@@ -55923,7 +55923,7 @@ msgstr ""
#: erpnext/stock/doctype/stock_entry/stock_entry.py:2111
msgid "There must be atleast 1 Finished Good in this Stock Entry"
msgstr "В этой записи о движении товаров должно быть хотя бы одно готовое изделие"
msgstr "Рэтой записи о движении товаров должно быть хотя бы одно готовое изделие"
#: erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py:153
msgid "There was an error creating Bank Account while linking with Plaid."
@@ -56344,7 +56344,7 @@ msgstr ""
#: erpnext/controllers/selling_controller.py:886
msgid "This {} will be treated as material transfer."
msgstr "Это {} будет рассматриваться как передача материала."
msgstr "Р­СРѕ {} будет рассматриваться как передача материала."
#. Option for the 'Under Withheld Reason' (Select) field in DocType 'Tax
#. Withholding Entry'
@@ -62032,7 +62032,7 @@ msgstr "Вы можете использовать {0} для сверки с {1
#: erpnext/manufacturing/doctype/job_card/job_card.py:1391
msgid "You can't make any changes to Job Card since Work Order is closed."
msgstr "Вы не можете вносить изменения в Карту работы, поскольку Заказ на работу закрыт."
msgstr "РС РЅРµ можете вносить изменения РІ Карту работы, поскольку Заказ РЅР° работу закрыт."
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:230
msgid "You can't process the serial number {0} as it has already been used in the SABB {1}. {2} if you want to inward same serial number multiple times then enabled 'Allow existing Serial No to be Manufactured/Received again' in the {3}"
@@ -62056,7 +62056,7 @@ msgstr ""
#: erpnext/accounts/general_ledger.py:851
msgid "You cannot create/amend any accounting entries till this date."
msgstr "Создание и изменение бухгалтерских записей невозможно до указанной даты."
msgstr "Создание и изменение бухгалтерских записей невозможно до указанной даты."
#: erpnext/accounts/doctype/journal_entry/journal_entry.py:961
msgid "You cannot credit and debit same account at the same time"
@@ -62084,7 +62084,7 @@ msgstr "Вы не можете обменять более {0}."
#: erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py:211
msgid "You cannot repost item valuation before {}"
msgstr "Невозможно повторно провести оценку стоимости товара до {}"
msgstr "Невозможно повторно провести оценку стоимости товара до {}"
#: erpnext/accounts/doctype/subscription/subscription.py:766
msgid "You cannot restart a Subscription that is not cancelled."
@@ -62918,7 +62918,7 @@ msgstr "{0} не включен в {1}"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:647
msgid "{0} is not running. Cannot trigger events for this Document"
msgstr "{0} не запущен. Невозможно запустить события для этого документа"
msgstr "{0} не запущен. Невозможно запустить события для этого документа"
#: erpnext/stock/doctype/material_request/material_request.py:835
msgid "{0} is not the default supplier for any items."

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Slovenian\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Serbian (Cyrillic)\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-09 11:03\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Serbian (Latin)\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-10 11:05\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -740,7 +740,7 @@ msgid "<h3>Currency Exchange Settings Help</h3>\n"
"<p>There are 3 variables that could be used within the endpoint, result key and in values of the parameter.</p>\n"
"<p>Exchange rate between {from_currency} and {to_currency} on {transaction_date} is fetched by the API.</p>\n"
"<p>Example: If your endpoint is exchange.com/2021-08-01, then, you will have to input exchange.com/{transaction_date}</p>"
msgstr "<h3>Växelkurs Inställningar Hjälp</h3>\n"
msgstr "<h3>Valutaväxling Inställningar Hjälp</h3>\n"
"<p>Det finns 3 variabler som kan användas av slutpunkt, resultat nyckel och i parameter värde.</p>\n"
"<p>Växelkurs mellan {from_currency} och {to_currency} {transaction_date} hämtas av API.</p>\n"
"<p>Exempel: Om slutpunkt är exchange.com/2021-08-01 måste du ange exchange.com/{transaction_date}</p>"
@@ -1079,7 +1079,7 @@ msgstr "Helg Lista kan läggas till för att utesluta dessa dagar för Arbetssta
#: erpnext/crm/doctype/lead/lead.py:142
msgid "A Lead requires either a person's name or an organization's name"
msgstr "Potentiell Kund erfordrar antingen person namn eller bolag namn"
msgstr "Potentiell Kund kräver antingen person namn eller bolag namn"
#: erpnext/stock/doctype/packing_slip/packing_slip.py:84
msgid "A Packing Slip can only be created for Draft Delivery Note."
@@ -1229,7 +1229,7 @@ msgstr "Service Avtal Utgång Datum"
#. Label of a Workspace Sidebar Item
#: erpnext/workspace_sidebar/financial_reports.json
msgid "AP Summary"
msgstr "Skuldöversikt"
msgstr "Skulder Översikt"
#. Label of the api_details_section (Section Break) field in DocType 'Currency
#. Exchange Settings'
@@ -1240,7 +1240,7 @@ msgstr "API Detaljer"
#. Label of a Workspace Sidebar Item
#: erpnext/workspace_sidebar/financial_reports.json
msgid "AR Summary"
msgstr "Fordringöversikt"
msgstr "Fordringar Översikt"
#. Label of the awb_number (Data) field in DocType 'Shipment'
#: erpnext/stock/doctype/shipment/shipment.json
@@ -2254,7 +2254,7 @@ msgstr "Fordring Rabatt Konto"
#: erpnext/accounts/report/accounts_receivable/accounts_receivable.js:205
#: erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.json
msgid "Accounts Receivable Summary"
msgstr "Fordringöversikt"
msgstr "Fordringar Översikt"
#. Label of the accounts_receivable_unpaid (Link) field in DocType 'Invoice
#. Discounting'
@@ -5232,7 +5232,7 @@ msgstr "Tillämpad Dimension"
#. Description of the 'Holiday List' (Link) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Applicable Holiday List"
msgstr "Tillämplig Helg Lista"
msgstr "Tillämpligt Helg Lista"
#. Label of the applicable_modules_section (Section Break) field in DocType
#. 'Terms and Conditions'
@@ -5250,22 +5250,22 @@ msgstr "Tillämplig På Konto"
#. Label of the to_designation (Link) field in DocType 'Authorization Rule'
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
msgid "Applicable To (Designation)"
msgstr "Tillämplig för (Befattning)"
msgstr "Tillämpligt för (Befattning)"
#. Label of the to_emp (Link) field in DocType 'Authorization Rule'
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
msgid "Applicable To (Employee)"
msgstr "Tillämplig för (Personal)"
msgstr "Tillämpligt för (Personal)"
#. Label of the system_role (Link) field in DocType 'Authorization Rule'
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
msgid "Applicable To (Role)"
msgstr "Tillämplig för (Roll)"
msgstr "Tillämpligt för (Roll)"
#. Label of the system_user (Link) field in DocType 'Authorization Rule'
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
msgid "Applicable To (User)"
msgstr "Tillämplig för (Användare)"
msgstr "Tillämpligt för (Användare)"
#. Label of the countries (Table) field in DocType 'Price List'
#: erpnext/stock/doctype/price_list/price_list.json
@@ -5281,19 +5281,19 @@ msgstr "Användare"
#. Description of the 'Transporter' (Link) field in DocType 'Driver'
#: erpnext/setup/doctype/driver/driver.json
msgid "Applicable for external driver"
msgstr "Tillämplig för extern Förare"
msgstr "Tillämpligt för extern Förare"
#: erpnext/regional/italy/setup.py:162
msgid "Applicable if the company is SpA, SApA or SRL"
msgstr "Tillämplig om bolag är SpA, SApA eller SRL"
msgstr "Tillämpligt om bolag är SpA, SApA eller SRL"
#: erpnext/regional/italy/setup.py:171
msgid "Applicable if the company is a limited liability company"
msgstr "Tillämplig om bolag är Aktie Bolag"
msgstr "Tillämpligt om bolag är Aktie Bolag"
#: erpnext/regional/italy/setup.py:122
msgid "Applicable if the company is an Individual or a Proprietorship"
msgstr "Tillämplig om bolag är en individ eller ett Privat Bolag"
msgstr "Tillämpligt om bolag är en individ eller ett Privat Bolag"
#. Label of the applicable_on_cumulative_expense (Check) field in DocType
#. 'Budget'
@@ -5305,7 +5305,7 @@ msgstr "Tillämplig på Ackumulerad Kostnad"
#. 'Budget'
#: erpnext/accounts/doctype/budget/budget.json
msgid "Applicable on Material Request"
msgstr "Tillämplig på Material Begäran"
msgstr "Tillämpligt på Material Begäran"
#. Description of the 'Allow Partial Payment' (Check) field in DocType 'POS
#. Profile'
@@ -5316,13 +5316,13 @@ msgstr "Tillämplig på Kassa Faktura"
#. Label of the applicable_on_purchase_order (Check) field in DocType 'Budget'
#: erpnext/accounts/doctype/budget/budget.json
msgid "Applicable on Purchase Order"
msgstr "Tillämplig på Inköp Order"
msgstr "Tillämpligt på Inköp Order"
#. Label of the applicable_on_booking_actual_expenses (Check) field in DocType
#. 'Budget'
#: erpnext/accounts/doctype/budget/budget.json
msgid "Applicable on booking actual expenses"
msgstr "Tillämplig vid Bokföring av Faktiska Kostnader"
msgstr "Tillämpligt vid Bokföring av Faktiska Kostnader"
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:10
#: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:10
@@ -5532,7 +5532,7 @@ msgstr "Tid Bokning Bekräftelse"
#: erpnext/crm/doctype/appointment/appointment.py:189
msgid "Appointment Confirmed"
msgstr "Tid Bokning Bekräftad"
msgstr "Tidsbokning Bekräftad"
#: erpnext/www/book_appointment/index.js:237
msgid "Appointment Created Successfully"
@@ -5554,7 +5554,7 @@ msgstr "Tid Bokning Varar (Minuter)"
#. 'Appointment Booking Settings'
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json
msgid "Appointment Scheduling"
msgstr "Tid Bokning Schemaläggning"
msgstr "Tidsbokning Schemaläggning"
#: erpnext/www/book_appointment/index.py:24
msgid "Appointment Scheduling Disabled"
@@ -5566,7 +5566,7 @@ msgstr "Tid Bokning är Inaktiverad för denna Webbplats"
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py:101
msgid "Appointment Scheduling needs to be enabled for Appointment Booking through portal."
msgstr "Tid Bokning Schemaläggning måste vara aktiverad för Tid Bokning via portal."
msgstr "Tidsbokning Schemaläggning måste vara aktiverad för Tidsbokning via portal."
#. Label of the appointment_with (Link) field in DocType 'Appointment'
#: erpnext/crm/doctype/appointment/appointment.json
@@ -5575,31 +5575,31 @@ msgstr "Tid Bokning med"
#: erpnext/crm/doctype/appointment/appointment.py:86
msgid "Appointment can only be scheduled up to {0} day(s) in advance."
msgstr "Tid Bokning kan endast schemaläggas upp till {0} dag(ar) i förväg."
msgstr "Tidsbokning kan endast schemaläggas upp till {0} dag(ar) i förväg."
#: erpnext/crm/doctype/appointment/appointment.py:79
msgid "Appointment cannot be scheduled for a past time."
msgstr "Tid Bokning kan inte schemaläggas för förfluten tid."
msgstr "Tidsbokning kan inte schemaläggas för förfluten tid."
#: erpnext/crm/doctype/appointment/appointment.py:98
msgid "Appointment cannot be scheduled on a holiday."
msgstr "Tid Bokning kan inte schemaläggas på helgdag."
msgstr "Tidsbokning kan inte schemaläggas på helgdag."
#: erpnext/www/book_appointment/verify/index.py:28
msgid "Appointment has been closed. Please book the appointment again."
msgstr "Tid Bokning har stängts. Boka igen."
msgstr "Tidsbokning har stängts. Boka igen."
#: erpnext/www/book_appointment/verify/index.py:33
msgid "Appointment is already verified."
msgstr "Tid Bokning är redan bekräftad."
msgstr "Tidsbokning är redan bekräftad."
#: erpnext/crm/doctype/appointment/appointment.py:116
msgid "Appointment must be scheduled within the available slot timings."
msgstr "Tid Bokning måste schemaläggas inom tillgänglig tidsintervall."
msgstr "Tidsbokning måste schemaläggas inom tillgänglig tidsintervall."
#: erpnext/crm/doctype/appointment/appointment.py:66
msgid "Appointments created manually cannot have 'Unverified' status."
msgstr "Tid Bokningar som skapas manuellt kan inte ha ”Overifierad” status."
msgstr "Tidsbokningar som skapas manuellt kan inte ha ”Overifierad” status."
#. Label of the approving_role (Link) field in DocType 'Authorization Rule'
#: erpnext/setup/doctype/authorization_rule/authorization_rule.json
@@ -6349,7 +6349,7 @@ msgstr "På Rad {0}: I Serie och Parti Paket {1} måste dokument status vara 1 o
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:84
msgid "At least one account with exchange gain or loss is required"
msgstr "Minst ett konto med Växelkurs Resultat erfordras"
msgstr "Minst ett konto med Valutaväxling Resultat erfordras"
#: erpnext/assets/doctype/asset/asset.py:1293
msgid "At least one asset has to be selected."
@@ -9884,7 +9884,7 @@ msgstr "Kan inte dra av när an kategori \"Värdering\" eller \"Värdering och T
#: erpnext/accounts/doctype/payment_entry/payment_entry.js:1854
msgid "Cannot delete Exchange Gain/Loss row"
msgstr "Kan inte ta bort Växelkurs Resultat rad"
msgstr "Kan inte ta bort Valutaväxling Resultat rad"
#: erpnext/stock/doctype/serial_no/serial_no.py:120
msgid "Cannot delete Serial No {0}, as it is used in stock transactions"
@@ -14325,7 +14325,7 @@ msgstr "Cup"
#: erpnext/setup/doctype/currency_exchange/currency_exchange.json
#: erpnext/workspace_sidebar/accounts_setup.json
msgid "Currency Exchange"
msgstr "Växelkurs"
msgstr "Valutaväxling"
#. Label of the currency_exchange_section (Section Break) field in DocType
#. 'Accounts Settings'
@@ -14336,21 +14336,21 @@ msgstr "Växelkurs"
#: erpnext/workspace_sidebar/accounts_setup.json
#: erpnext/workspace_sidebar/erpnext_settings.json
msgid "Currency Exchange Settings"
msgstr "Växelkurs Inställningar"
msgstr "Valutaväxling Inställningar"
#. Name of a DocType
#: erpnext/accounts/doctype/currency_exchange_settings_details/currency_exchange_settings_details.json
msgid "Currency Exchange Settings Details"
msgstr "Växelkurs Inställning Detaljer"
msgstr "Valutaväxling Inställning Detaljer"
#. Name of a DocType
#: erpnext/accounts/doctype/currency_exchange_settings_result/currency_exchange_settings_result.json
msgid "Currency Exchange Settings Result"
msgstr "Växelkurs Inställning Resultat"
msgstr "Valutaväxling Inställning Resultat"
#: erpnext/setup/doctype/currency_exchange/currency_exchange.py:55
msgid "Currency Exchange must be applicable for Buying or for Selling."
msgstr "Växelkurs måste vara tillämplig för Inköp eller Försäljning."
msgstr "Valutaväxling måste vara tillämplig för Inköp eller Försäljning."
#. Label of the currency_and_price_list (Section Break) field in DocType 'POS
#. Invoice'
@@ -18183,7 +18183,7 @@ msgstr "Driftstopp Tid (Timmar)"
#: erpnext/manufacturing/workspace/manufacturing/manufacturing.json
#: erpnext/workspace_sidebar/manufacturing.json
msgid "Downtime Analysis"
msgstr "Driftstopp Statistik"
msgstr "Driftstopp Analys"
#. Name of a DocType
#. Label of a Link in the Manufacturing Workspace
@@ -19009,7 +19009,7 @@ msgstr "Aktivera Tillåt Partiell Reservation i Lager Inställningar för att re
#. Booking Settings'
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.json
msgid "Enable Appointment Booking Through Portal"
msgstr "Aktivera Tid Bokning via Portal"
msgstr "Aktivera Tidsbokning via Portal"
#. Label of the enable_scheduling (Check) field in DocType 'Appointment Booking
#. Settings'
@@ -19280,7 +19280,7 @@ msgstr "Aktivera för att göra denna leverantör valbar som transportör på F
#. Description of the 'Retain Sample' (Check) field in DocType 'Item'
#: erpnext/stock/doctype/item/item.json
msgid "Enable to reserve a small sample from each batch for any analysis arising ahead"
msgstr "Aktivera för att reservera litet prov från varje parti för statistik som uppstår senare"
msgstr "Aktivera för att reservera litet prov från varje parti för analys som uppstår senare"
#. Label of the enable_tracking_sales_commissions (Check) field in DocType
#. 'Selling Settings'
@@ -19752,17 +19752,17 @@ msgstr "Inställning"
#. 'Company'
#: erpnext/setup/doctype/company/company.json
msgid "Exchange Gain / Loss"
msgstr "Växelkurs Resultat"
msgstr "Valutaväxling Resultat"
#. Label of the exchange_gain_loss_account (Link) field in DocType 'Company'
#: erpnext/setup/doctype/company/company.json
msgid "Exchange Gain / Loss Account"
msgstr "Växelkurs Resultat Konto"
msgstr "Valutaväxling Resultat Konto"
#. Option for the 'Entry Type' (Select) field in DocType 'Journal Entry'
#: erpnext/accounts/doctype/journal_entry/journal_entry.json
msgid "Exchange Gain Or Loss"
msgstr "Växelkurs Resultat"
msgstr "Valutaväxling Resultat"
#. Label of the exchange_gain_loss (Currency) field in DocType 'Payment Entry
#. Reference'
@@ -19777,12 +19777,12 @@ msgstr "Växelkurs Resultat"
#: erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json
#: erpnext/setup/doctype/company/company.py:677
msgid "Exchange Gain/Loss"
msgstr "Växelkurs Resultat"
msgstr "Valutaväxling Resultat"
#: erpnext/controllers/accounts_controller.py:1809
#: erpnext/controllers/accounts_controller.py:1894
msgid "Exchange Gain/Loss amount has been booked through {0}"
msgstr "Växelkurs Resultat Belopp har bokförts genom {0}"
msgstr "Valutaväxling Resultat Belopp har bokförts genom {0}"
#. Label of the exchange_rate (Float) field in DocType 'Advance Payment Ledger
#. Entry'
@@ -23282,7 +23282,7 @@ msgstr "Här kan du välja överordnade för Personal. Baserat på detta kommer
#: erpnext/setup/doctype/holiday_list/holiday_list.js:77
msgid "Here, your weekly offs are pre-populated based on the previous selections. You can add more rows to also add public and national holidays individually."
msgstr "Här är dina veckofrånvaro förifyllda baserat på tidigare val. Du kan lägga till fler rader för att även lägga till allmänna och nationella helgdagar individuellt."
msgstr "Här är dina veckoledigheter förifyllda baserat på tidigare val. Du kan lägga till fler rader för att även lägga till allmänna och nationella helgdagar individuellt."
#. Name of a UOM
#: erpnext/setup/setup_wizard/data/uom_data.json
@@ -23725,7 +23725,7 @@ msgstr "Om aktiverad, kommer utskrift av detta dokument att bifogas till varje e
#. (Check) field in DocType 'Stock Reposting Settings'
#: erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json
msgid "If enabled, a weekly scheduler scans the Stock Ledger Variance for item-warehouses with incorrect valuation in the current financial year and auto-creates Item & Warehouse based reposts to fix them."
msgstr "Om aktiverad, veckovis schemaläggare skannar Lager Register Avvikelse efter lager artikel med felaktig värdering under innevarande bokföring år och automatiskt skapar Artikel & Lager baserade ombokningar för att fixa dem."
msgstr "Om aktiverad, en veckovis schemaläggare skannar Lager Register Avvikelse efter lager artikel med felaktig värdering under innevarande bokföring år och automatiskt skapar Artikel & Lager baserade ombokningar för att fixa dem."
#. Description of the 'Enable discount accounting for selling' (Check) field in
#. DocType 'Selling Settings'
@@ -26208,7 +26208,7 @@ msgstr "Är Rabatterad"
#. Deduction'
#: erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json
msgid "Is Exchange Gain / Loss?"
msgstr "Är Växelkurs Resultat?"
msgstr "Är Valutaväxling Resultat?"
#. Label of the is_expandable (Check) field in DocType 'BOM Creator Item'
#: erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json
@@ -29270,7 +29270,7 @@ msgstr "Begränsad till tolv tecken"
#. Settings'
#: erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json
msgid "Limits don't apply on"
msgstr "Begränsning gäller inte för"
msgstr "Begränsningar gäller inte för"
#. Label of the reference_code (Data) field in DocType 'Financial Report Row'
#: erpnext/accounts/doctype/financial_report_row/financial_report_row.json
@@ -32703,7 +32703,7 @@ msgstr "Inga extra fält tillgängliga"
#: erpnext/crm/doctype/appointment/appointment.py:103
msgid "No availability of slots are found. Please add on Appointment Booking Settings."
msgstr "Inga lediga tider hittades. Lägg till detta i Tid Bokning Inställningar."
msgstr "Inga lediga tider hittades. Lägg till detta i Tidsbokning Inställningar."
#: erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py:1367
msgid "No available quantity to reserve for item {0} in warehouse {1}"
@@ -32905,7 +32905,7 @@ msgstr "Inga utestående fakturor hittades"
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:353
msgid "No outstanding invoices require exchange rate revaluation"
msgstr "Inga utestående fakturor erfordrar växelkurs omvärdering"
msgstr "Inga utestående fakturor kräver växelkurs omvärdering"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:2454
msgid "No outstanding {0} found for the {1} {2} which qualify the filters you have specified."
@@ -33021,7 +33021,7 @@ msgstr "Inga verifikat hittades för denna transaktion"
#: erpnext/accounts/doctype/sales_invoice/sales_invoice.py:2637
msgid "No {0} found for Inter Company Transactions."
msgstr "{0} hittades inte för Inter Bolag Transaktioner."
msgstr "Ingen {0} hittades för Inter Bolag Transaktioner."
#. Label of the no_of_employees (Select) field in DocType 'Prospect'
#: erpnext/crm/doctype/prospect/prospect.json
@@ -35007,7 +35007,7 @@ msgstr "PDF Tabeller"
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:930
msgid "PDF statement support requires the 'pdfplumber' library to be installed."
msgstr "Stöd för PDF kontoutdrag erfordrar att bibliotek \"pdfplumber\" är installerad."
msgstr "Stöd för PDF kontoutdrag kräver att bibliotek \"pdfplumber\" är installerad."
#. Label of the pin (Data) field in DocType 'Warehouse'
#: erpnext/stock/doctype/warehouse/warehouse.json
@@ -35418,7 +35418,7 @@ msgstr "Sidbrytning efter varje SoA"
#: banking/src/components/features/BankStatementImporter/PDF/PDFTableEditor.tsx:302
msgid "Page preview"
msgstr "Förhandsvisa Sida"
msgstr "Förhandsgranska Sida"
#. Option for the 'Status' (Select) field in DocType 'Payment Request'
#. Option for the 'Status' (Select) field in DocType 'POS Invoice'
@@ -37911,7 +37911,7 @@ msgstr "Lägg till Tillfällig Öppning Konto i Kontoplan"
#: erpnext/crm/doctype/appointment/appointment.py:95
msgid "Please add a valid Holiday List on Appointment Booking Settings."
msgstr "Lägg till giltig Helgdag Lista i Tid Bokning Inställningar."
msgstr "Lägg till giltig Helgdag Lista i Tidsbokning Inställningar."
#: erpnext/accounts/doctype/bank_transaction_rule/bank_transaction_rule.py:119
msgid "Please add an account for the Bank Entry rule."
@@ -38073,11 +38073,11 @@ msgstr "Skapa inte mer än 500 Artiklar åt gång"
#: erpnext/accounts/doctype/budget/budget.py:182
msgid "Please enable Applicable on Booking Actual Expenses"
msgstr "Aktivera Tillämplig vid Bokföring av Faktiska Kostnader"
msgstr "Aktivera Tillämpligt vid Bokföring av Faktiska Kostnader"
#: erpnext/accounts/doctype/budget/budget.py:178
msgid "Please enable Applicable on Purchase Order and Applicable on Booking Actual Expenses"
msgstr "Aktivera Tillämplig vid Inköp Order och Tillämplig vid Bokföring av Faktiska Kostnader"
msgstr "Aktivera Tillämpligt vid Inköp Order och Tillämpligt vid Bokföring av Faktiska Kostnader"
#: erpnext/stock/doctype/pick_list/pick_list.py:323
msgid "Please enable Use Old Serial / Batch Fields to make_bundle"
@@ -38296,7 +38296,7 @@ msgstr "Fyll i Försäljning Order Tabell"
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py:57
msgid "Please fill up the Availability of Slots table to enable Appointment Scheduling."
msgstr "Fyll i tabell ”Lediga Tider” för att aktivera Tid Bokning Schemaläggning."
msgstr "Fyll i tabell ”Lediga Tider” för att aktivera Tidsbokning Schemaläggning."
#: erpnext/stock/doctype/shipment/shipment.js:277
msgid "Please first set Full Name, Email and Phone for the user"
@@ -38540,7 +38540,7 @@ msgstr "Välj Försäljning Följesedel"
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py:81
msgid "Please select a Holiday List to enable Appointment Scheduling."
msgstr "Välj Helgdag Lista för att aktivera Tid Bokning Schemaläggning."
msgstr "Välj Helgdag Lista för att aktivera Tidsbokning Schemaläggning."
#: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py:153
msgid "Please select a Subcontracting Purchase Order."
@@ -38847,7 +38847,7 @@ msgstr "Ange Org.Nr. for Kund '%s'"
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:364
msgid "Please set Unrealized Exchange Gain/Loss Account in Company {0}"
msgstr "Ange Orealiserat Växelkurs Resultat Konto i Bolag {0}"
msgstr "Ange Orealiserat Valutaväxling Resultat Konto i Bolag {0}"
#: erpnext/regional/report/vat_audit_report/vat_audit_report.py:56
msgid "Please set VAT Accounts in {0}"
@@ -38920,7 +38920,7 @@ msgstr "Ange Standard Kassa eller Bank Konto i Betalning Sätt {}"
#: erpnext/accounts/utils.py:2522
msgid "Please set default Exchange Gain/Loss Account in Company {}"
msgstr "Ange Standard Växelkurs Resultat Konto för Bolag {}"
msgstr "Ange Standard Valutaväxling Resultat Konto för Bolag {}"
#: erpnext/assets/doctype/asset_repair/asset_repair.py:389
msgid "Please set default Expense Account in Company {0}"
@@ -39017,7 +39017,7 @@ msgstr "Ange {0} i {1} eller i Artikel Standard Inställningar {2}"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:1160
msgid "Please set {0} in Company {1} to account for Exchange Gain / Loss"
msgstr "Ange {0} i Bolag {1} för att bokföra växelkurs resultat"
msgstr "Ange {0} i Bolag {1} för att bokföra valutaväxling resultat"
#: erpnext/controllers/accounts_controller.py:618
msgid "Please set {0} to {1}, the same account that was used in the original invoice {2}."
@@ -39296,7 +39296,7 @@ msgstr "Registrering Datum kan inte vara i framtiden"
#. 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Posting Date inheritance for exchange gain / loss"
msgstr "Bokföring Datum arv för växelkurs resultat"
msgstr "Bokföring Datum arv för valutaväxling resultat"
#: erpnext/public/js/controllers/transaction.js:1139
msgid "Posting Date will change to today's date as Edit Posting Date and Time is unchecked. Are you sure want to proceed?"
@@ -39538,17 +39538,17 @@ msgstr "Förhindrar att system automatiskt använder pris från senaste inköp t
#: erpnext/buying/doctype/request_for_quotation/request_for_quotation.js:267
#: erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
msgid "Preview Email"
msgstr "Förhandsvisa E-post"
msgstr "Förhandsgranska E-post"
#. Label of the download_materials_request_plan_section_section (Section Break)
#. field in DocType 'Production Plan'
#: erpnext/manufacturing/doctype/production_plan/production_plan.json
msgid "Preview Required Materials"
msgstr "Förhandsvisa Erfordrad Material"
msgstr "Förhandsgranska Erfordrad Material"
#: banking/src/components/features/BankStatementImporter/CSV/StatementDetails.tsx:230
msgid "Preview Transactions"
msgstr "Förhandsvisa Transaktioner"
msgstr "Förhandsgranska Transaktioner"
#: erpnext/accounts/report/balance_sheet/balance_sheet.py:191
#: erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py:142
@@ -40053,7 +40053,7 @@ msgstr "Skriv ut"
#. Profile'
#: erpnext/accounts/doctype/pos_profile/pos_profile.json
msgid "Print Receipt on Order Complete"
msgstr "Skriv ut när Order är klar"
msgstr "Skriv ut kvitto när Order är klar"
#: erpnext/setup/install.py:108
msgid "Print UOM after Quantity"
@@ -44374,7 +44374,7 @@ msgstr "Tog bort {0} rader med noll dokument antal. Spara för att ändringarna
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:87
msgid "Removing rows without exchange gain or loss"
msgstr "Tar bort rader utan Växelkurs Resultat"
msgstr "Tar bort rader utan Valutaväxling Resultat"
#. Description of the 'Allow Rename Attribute Value' (Check) field in DocType
#. 'Item Variant Settings'
@@ -45908,7 +45908,7 @@ msgstr "Roll att avisera vid Avskrivning Fel"
#. 'Company'
#: erpnext/setup/doctype/company/company.json
msgid "Roles Allowed to Set and Edit Frozen Account Entries"
msgstr "Roller som får Ange och Redigera sta Konto Poster"
msgstr "Roller som har behörighet att skapa och redigera stängda konto poster"
#. Label of the root (Link) field in DocType 'Bisect Nodes'
#: erpnext/accounts/doctype/bisect_nodes/bisect_nodes.json
@@ -58970,7 +58970,7 @@ msgstr "Okvalificerad"
#. 'Company'
#: erpnext/setup/doctype/company/company.json
msgid "Unrealized Exchange Gain/Loss Account"
msgstr "Orealiserad Växelkurs Resultat Konto"
msgstr "Orealiserad Valutaväxling Resultat Konto"
#. Label of the unrealized_profit_loss_account (Link) field in DocType
#. 'Purchase Invoice'
@@ -59007,12 +59007,12 @@ msgstr "Ångra Avstämning"
#: erpnext/workspace_sidebar/invoicing.json
#: erpnext/workspace_sidebar/payments.json
msgid "Unreconcile Payment"
msgstr "Ångrad Betalning Avstämning"
msgstr "Ångra Betalning Avstämning"
#. Name of a DocType
#: erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json
msgid "Unreconcile Payment Entries"
msgstr "Ångrad Betalning Avstämning Post"
msgstr "Ångra Betalning Avstämning Post"
#: erpnext/accounts/doctype/bank_transaction/bank_transaction.js:40
msgid "Unreconcile Transaction"
@@ -62887,11 +62887,11 @@ msgstr "{0} är erfodrad för konto {1}"
#: erpnext/public/js/controllers/taxes_and_totals.js:132
msgid "{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}"
msgstr "{0} är erfordrad. Kanske Växelkurs Post är inte skapad för {1} till {2}"
msgstr "{0} är erfordrad. Kanske Valutaväxling Post är inte skapad för {1} till {2}"
#: erpnext/controllers/accounts_controller.py:3212
msgid "{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}."
msgstr "{0} är erfordrad. Kanske Växelkurs Post är inte skapad för {1} till {2}."
msgstr "{0} är erfordrad. Kanske Valutaväxling Post är inte skapad för {1} till {2}."
#: erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py:1929
msgid "{0} is not a CSV file."

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:44\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Thai\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:44\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Turkish\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:44\n"
"PO-Revision-Date: 2026-08-09 11:03\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Uzbek\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:44\n"
"PO-Revision-Date: 2026-08-09 11:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Vietnamese\n"
"MIME-Version: 1.0\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-09 09:47+0000\n"
"PO-Revision-Date: 2026-08-26 11:43\n"
"PO-Revision-Date: 2026-08-11 23:05\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Chinese Simplified\n"
"MIME-Version: 1.0\n"
@@ -51016,7 +51016,7 @@ msgstr ""
#. Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Show balances in Chart of Accounts"
msgstr "在会计科目表中显示余额"
msgstr ""
#. Label of the show_barcode_field (Check) field in DocType 'Stock Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,6 @@
frappe.ui.form.on("Blanket Order", {
onload: function (frm) {
frm.trigger("set_tc_name_filter");
if (frm.is_new()) {
let has_pricing = frm.doc.currency || frm.doc.selling_price_list || frm.doc.buying_price_list;
blanket_order_pricing.apply(frm, null, { reset_party_values: !has_pricing });
}
},
setup: function (frm) {
@@ -19,13 +15,10 @@ frappe.ui.form.on("Blanket Order", {
frm.add_fetch("customer", "customer_name", "customer_name");
frm.add_fetch("supplier", "supplier_name", "supplier_name");
frm.set_query("selling_price_list", () => ({ filters: { selling: 1 } }));
frm.set_query("buying_price_list", () => ({ filters: { buying: 1 } }));
},
refresh: function (frm) {
erpnext.hide_company(frm);
blanket_order_pricing.update_labels(frm);
if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) {
frm.add_custom_button(
__("Sales Order"),
@@ -108,141 +101,5 @@ frappe.ui.form.on("Blanket Order", {
blanket_order_type: function (frm) {
frm.trigger("set_tc_name_filter");
return reset_party_pricing(frm);
},
company: reset_party_pricing,
customer: reset_party_pricing,
supplier: reset_party_pricing,
currency: function (frm) {
return blanket_order_pricing.apply(frm, null, { reset_conversion_rate: true });
},
from_date: function (frm) {
return blanket_order_pricing.apply(frm, null, {
reset_conversion_rate: true,
reset_plc_conversion_rate: true,
});
},
conversion_rate: async function (frm) {
await blanket_order_pricing.update_base_rates(frm);
return blanket_order_pricing.apply(frm);
},
selling_price_list: reset_price_list_exchange_rate,
buying_price_list: reset_price_list_exchange_rate,
plc_conversion_rate: function (frm) {
return blanket_order_pricing.apply(frm);
},
});
frappe.ui.form.on("Blanket Order Item", {
item_code: apply_item_pricing,
qty: apply_item_pricing,
rate: function (frm, cdt, cdn) {
return set_base_rate(frm, frappe.get_doc(cdt, cdn));
},
});
const blanket_order_pricing = {
update_base_rates(frm) {
return Promise.all((frm.doc.items || []).map((item) => set_base_rate(frm, item)));
},
update_labels(frm) {
let company_currency = this.get_company_currency(frm);
let show_base_rate = Boolean(
frm.doc.currency && company_currency && frm.doc.currency !== company_currency
);
frm.set_currency_labels(["price_list_rate", "rate"], frm.doc.currency || company_currency, "items");
frm.set_currency_labels(["base_price_list_rate", "base_rate"], company_currency, "items");
frm.fields_dict.items.grid.set_column_disp("base_price_list_rate", show_base_rate);
frm.fields_dict.items.grid.set_column_disp("base_rate", show_base_rate);
frm.toggle_display("conversion_rate", show_base_rate);
frm.toggle_display(
"plc_conversion_rate",
Boolean(frm.doc.price_list_currency && frm.doc.price_list_currency !== company_currency)
);
frm.set_df_property(
"conversion_rate",
"description",
show_base_rate ? `1 ${frm.doc.currency} = [?] ${company_currency}` : ""
);
frm.refresh_fields();
},
get_company_currency(frm) {
return frm.doc.company ? erpnext.get_currency(frm.doc.company) : null;
},
async apply(frm, item_name = null, options = {}) {
if (!frm.doc.company || !frm.doc.blanket_order_type) {
return;
}
if (frm.__applying_blanket_order_price_list) {
frm.__pending_blanket_order_price_list = { item_name, options };
return;
}
frm.__applying_blanket_order_price_list = true;
let pending;
try {
let response = await frappe.call({
method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.apply_price_list",
args: {
doc: frm.doc,
item_name,
reset_party_values: options.reset_party_values,
reset_conversion_rate: options.reset_conversion_rate,
reset_plc_conversion_rate: options.reset_plc_conversion_rate,
},
});
if (response.message) {
await frm.set_value(response.message.parent);
for (const values of response.message.children) {
let { name, ...fields } = values;
let item = (frm.doc.items || []).find((row) => row.name === name);
if (item) {
await frappe.model.set_value(item.doctype, item.name, fields);
}
}
this.update_labels(frm);
}
} finally {
frm.__applying_blanket_order_price_list = false;
pending = frm.__pending_blanket_order_price_list;
frm.__pending_blanket_order_price_list = null;
}
if (pending) {
return this.apply(frm, pending.item_name, pending.options);
}
},
};
function reset_party_pricing(frm) {
return blanket_order_pricing.apply(frm, null, { reset_party_values: true });
}
function reset_price_list_exchange_rate(frm) {
return blanket_order_pricing.apply(frm, null, { reset_plc_conversion_rate: true });
}
function apply_item_pricing(frm, cdt, cdn) {
return blanket_order_pricing.apply(frm, cdn);
}
function set_base_rate(frm, item) {
frappe.model.round_floats_in(item, ["rate"]);
let base_rate = flt(flt(item.rate) * flt(frm.doc.conversion_rate), precision("base_rate", item));
return frappe.model.set_value(item.doctype, item.name, "base_rate", base_rate);
}

View File

@@ -18,14 +18,6 @@
"from_date",
"to_date",
"company",
"currency_and_price_list",
"currency",
"conversion_rate",
"column_break_price_list",
"selling_price_list",
"buying_price_list",
"price_list_currency",
"plc_conversion_rate",
"section_break_12",
"items",
"amended_from",
@@ -104,66 +96,6 @@
"reqd": 1,
"search_index": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency",
"print_hide": 1,
"reqd": 1
},
{
"fieldname": "column_break_price_list",
"fieldtype": "Column Break"
},
{
"description": "Rate at which document currency is converted to company currency",
"fieldname": "conversion_rate",
"fieldtype": "Float",
"label": "Exchange Rate",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
{
"depends_on": "eval:doc.blanket_order_type == \"Selling\"",
"fieldname": "selling_price_list",
"fieldtype": "Link",
"label": "Price List",
"options": "Price List",
"print_hide": 1
},
{
"depends_on": "eval:doc.blanket_order_type == \"Purchasing\"",
"fieldname": "buying_price_list",
"fieldtype": "Link",
"label": "Price List",
"options": "Price List",
"print_hide": 1
},
{
"fieldname": "price_list_currency",
"fieldtype": "Link",
"label": "Price List Currency",
"options": "Currency",
"print_hide": 1,
"read_only": 1
},
{
"description": "Rate at which Price List Currency is converted to Company Currency",
"fieldname": "plc_conversion_rate",
"fieldtype": "Float",
"label": "Price List Exchange Rate",
"precision": "9",
"print_hide": 1
},
{
"fieldname": "section_break_12",
"fieldtype": "Section Break"
@@ -215,7 +147,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2024-12-05 15:44:21.520093",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order",
@@ -243,4 +175,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -9,9 +9,6 @@ from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext import get_company_currency
from erpnext.controllers.accounts_controller import validate_conversion_rate
from erpnext.manufacturing.doctype.blanket_order import blanket_order_pricing
from erpnext.stock.doctype.item.item import get_item_defaults
@@ -28,10 +25,7 @@ class BlanketOrder(Document):
amended_from: DF.Link | None
blanket_order_type: DF.Literal["", "Selling", "Purchasing"]
buying_price_list: DF.Link | None
company: DF.Link
conversion_rate: DF.Float
currency: DF.Link
customer: DF.Link | None
customer_name: DF.Data | None
from_date: DF.Date
@@ -39,9 +33,6 @@ class BlanketOrder(Document):
naming_series: DF.Literal["MFG-BLR-.YYYY.-"]
order_date: DF.Date | None
order_no: DF.Data | None
plc_conversion_rate: DF.Float
price_list_currency: DF.Link | None
selling_price_list: DF.Link | None
supplier: DF.Link | None
supplier_name: DF.Data | None
tc_name: DF.Link | None
@@ -49,42 +40,11 @@ class BlanketOrder(Document):
to_date: DF.Date
# end: auto-generated types
def before_validate(self):
self.set_currency()
self.set_conversion_rate()
blanket_order_pricing.set_price_list(self)
def validate(self):
self.validate_dates()
self.validate_duplicate_items()
self.validate_item_qty()
self.set_party_item_code()
self.set_base_rates()
def set_currency(self):
if self.currency:
return
config = blanket_order_pricing.get_order_type_config(self.blanket_order_type)
party_type = config["party_type"]
party = self.get(config["party_field"])
party_currency = frappe.get_cached_value(party_type, party, "default_currency") if party else None
self.currency = party_currency or get_company_currency(self.company)
def set_conversion_rate(self):
company_currency = get_company_currency(self.company)
if self.currency == company_currency:
self.conversion_rate = 1.0
elif not self.conversion_rate:
self.conversion_rate = blanket_order_pricing.get_exchange_rate_to_company(self, self.currency)
validate_conversion_rate(
self.currency,
self.conversion_rate,
self.meta.get_translated_label("conversion_rate"),
self.company,
)
self.conversion_rate = flt(self.conversion_rate, self.precision("conversion_rate"))
def validate_dates(self):
if getdate(self.from_date) > getdate(self.to_date):
@@ -163,26 +123,6 @@ class BlanketOrder(Document):
if flt(d.qty) < 0:
frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx))
def set_base_rates(self):
blanket_order_pricing.set_base_rates(self)
@frappe.whitelist()
def apply_price_list(
doc: str | dict,
item_name: str | None = None,
reset_party_values: bool = False,
reset_plc_conversion_rate: bool = False,
reset_conversion_rate: bool = False,
):
return blanket_order_pricing.apply_price_list(
doc=doc,
item_name=item_name,
reset_party_values=reset_party_values,
reset_plc_conversion_rate=reset_plc_conversion_rate,
reset_conversion_rate=reset_conversion_rate,
)
@frappe.whitelist()
def make_order(source_name):
@@ -196,12 +136,14 @@ def make_order(source_name):
def update_item(source, target, source_parent):
target_qty = source.get("qty") - source.get("ordered_qty")
target.qty = target_qty if flt(target_qty) >= 0 else 0
target.rate = source.get("rate")
item = get_item_defaults(target.item_code, source_parent.company)
if item:
target.item_name = item.get("item_name")
target.description = item.get("description")
target.uom = item.get("stock_uom")
target.against_blanket_order = 1
target.blanket_order = source_name
target_doc = get_mapped_doc(
"Blanket Order",
@@ -210,10 +152,7 @@ def make_order(source_name):
"Blanket Order": {"doctype": doctype, "postprocess": update_doc},
"Blanket Order Item": {
"doctype": doctype + " Item",
"field_map": {
"rate": "blanket_order_rate",
"parent": "blanket_order",
},
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
"postprocess": update_item,
"condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0,
},

View File

@@ -1,218 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.utils import cint, flt
from erpnext import get_company_currency
from erpnext.accounts.party import get_default_price_list as get_party_default_price_list
from erpnext.controllers.accounts_controller import validate_conversion_rate
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.get_item_details import get_item_prices_for_stock_uom
_ORDER_TYPE_CONFIG = {
"Selling": {
"exchange_rate_type": "for_selling",
"opposite_price_list_field": "buying_price_list",
"party_field": "customer",
"party_type": "Customer",
"price_list_field": "selling_price_list",
"price_list_type": "Selling",
"settings_doctype": "Selling Settings",
},
"Purchasing": {
"exchange_rate_type": "for_buying",
"opposite_price_list_field": "selling_price_list",
"party_field": "supplier",
"party_type": "Supplier",
"price_list_field": "buying_price_list",
"price_list_type": "Buying",
"settings_doctype": "Buying Settings",
},
}
def get_order_type_config(blanket_order_type):
return _ORDER_TYPE_CONFIG[blanket_order_type]
def get_exchange_rate_to_company(doc, currency):
config = get_order_type_config(doc.blanket_order_type)
return get_exchange_rate(
currency,
get_company_currency(doc.company),
doc.from_date,
config["exchange_rate_type"],
)
def set_price_list(doc, set_default=False, force_exchange_rate=False):
config = get_order_type_config(doc.blanket_order_type)
fieldname = config["price_list_field"]
doc.set(config["opposite_price_list_field"], None)
if not doc.get(fieldname) and (doc.is_new() or set_default):
doc.set(fieldname, get_default_price_list(doc))
price_list = doc.get(fieldname)
if not price_list:
clear_price_list(doc)
return
price_list_type = config["price_list_type"].lower()
price_list_details = frappe.get_cached_value(
"Price List", price_list, ["currency", price_list_type, "enabled"], as_dict=True
)
if not price_list_details or not price_list_details.enabled:
frappe.throw(_("Price List {0} is disabled or does not exist").format(frappe.bold(price_list)))
if not price_list_details.get(price_list_type):
frappe.throw(
_("Price List {0} is not enabled for {1}").format(
frappe.bold(price_list), frappe.bold(doc.blanket_order_type)
)
)
price_list_currency_changed = doc.price_list_currency != price_list_details.currency
doc.price_list_currency = price_list_details.currency
company_currency = get_company_currency(doc.company)
if doc.price_list_currency == company_currency:
doc.plc_conversion_rate = 1.0
elif price_list_currency_changed or not doc.plc_conversion_rate or force_exchange_rate:
doc.plc_conversion_rate = get_exchange_rate_to_company(doc, doc.price_list_currency)
validate_conversion_rate(
doc.price_list_currency,
doc.plc_conversion_rate,
doc.meta.get_translated_label("plc_conversion_rate"),
doc.company,
)
doc.plc_conversion_rate = flt(doc.plc_conversion_rate, doc.precision("plc_conversion_rate"))
def clear_price_list(doc):
doc.price_list_currency = None
doc.plc_conversion_rate = 0
for item in doc.items:
item.price_list_rate = 0
item.base_price_list_rate = 0
def get_default_price_list(doc):
config = get_order_type_config(doc.blanket_order_type)
party_type = config["party_type"]
party = doc.get(config["party_field"])
if party:
party_price_list = get_party_default_price_list(frappe.get_cached_doc(party_type, party))
if party_price_list:
return party_price_list
return frappe.db.get_single_value(config["settings_doctype"], config["price_list_field"])
def get_price_list_rates(doc, item_name=None):
price_list = doc.get(get_order_type_config(doc.blanket_order_type)["price_list_field"])
items = [item for item in doc.items if item.item_code and (not item_name or item.name == item_name)]
if not items:
return []
if not price_list:
return [{"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0} for item in items]
ctx = frappe._dict(
{
"price_list": price_list,
"customer": doc.customer,
"supplier": doc.supplier,
"transaction_date": doc.from_date,
}
)
item_prices = get_item_prices_for_stock_uom(ctx, list(dict.fromkeys(item.item_code for item in items)))
rates = []
for item in items:
item_price = item_prices.get(item.item_code)
qty = flt(item.qty) or 1
packing_unit = flt(item_price.packing_unit) if item_price else 0
price_list_rate = (
item_price.price_list_rate
if item_price and (not packing_unit or qty % packing_unit == 0)
else None
)
rate_details = {"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0}
if price_list_rate is not None:
rate = flt(price_list_rate) * flt(doc.plc_conversion_rate) / flt(doc.conversion_rate)
price_list_rate, base_price_list_rate = get_rate_and_base_amount(
doc, item, "price_list_rate", rate
)
rate, base_rate = get_rate_and_base_amount(doc, item, "rate", rate)
rate_details.update(
{
"price_list_rate": price_list_rate,
"base_price_list_rate": base_price_list_rate,
"rate": rate,
"base_rate": base_rate,
}
)
rates.append(rate_details)
return rates
def set_base_rates(doc):
for item in doc.items:
for fieldname in ("price_list_rate", "rate"):
rate, base_rate = get_rate_and_base_amount(doc, item, fieldname, item.get(fieldname))
item.set(fieldname, rate)
item.set(f"base_{fieldname}", base_rate)
def get_rate_and_base_amount(doc, item, fieldname, rate):
rate = flt(rate, item.precision(fieldname))
base_fieldname = f"base_{fieldname}"
base_rate = flt(rate * flt(doc.conversion_rate), item.precision(base_fieldname))
return rate, base_rate
def apply_price_list(
doc,
item_name=None,
reset_party_values=False,
reset_plc_conversion_rate=False,
reset_conversion_rate=False,
):
doc = frappe.get_doc(frappe.parse_json(doc))
reset_party_values = cint(reset_party_values)
reset_plc_conversion_rate = cint(reset_plc_conversion_rate)
reset_conversion_rate = cint(reset_conversion_rate)
if reset_party_values:
doc.currency = None
doc.conversion_rate = 0
doc.selling_price_list = None
doc.buying_price_list = None
doc.price_list_currency = None
doc.plc_conversion_rate = 0
else:
if reset_conversion_rate:
doc.conversion_rate = 0
if reset_plc_conversion_rate:
doc.plc_conversion_rate = 0
doc.set_currency()
doc.set_conversion_rate()
set_price_list(
doc,
set_default=reset_party_values,
force_exchange_rate=reset_party_values or reset_plc_conversion_rate,
)
return {
"parent": {
"currency": doc.currency,
"conversion_rate": doc.conversion_rate,
"selling_price_list": doc.selling_price_list,
"buying_price_list": doc.buying_price_list,
"price_list_currency": doc.price_list_currency,
"plc_conversion_rate": doc.plc_conversion_rate,
},
"children": get_price_list_rates(doc, item_name),
}

View File

@@ -1,18 +1,13 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from frappe.utils import add_months, flt, today
from frappe.utils import add_months, today
from erpnext import get_company_currency
from erpnext.controllers.queries import get_blanket_orders
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.get_item_details import get_blanket_order_details
from erpnext.tests.utils import ERPNextTestSuite
from . import blanket_order_pricing
from .blanket_order import apply_price_list, make_order
from .blanket_order import make_order
class TestBlanketOrder(ERPNextTestSuite):
@@ -144,241 +139,21 @@ class TestBlanketOrder(ERPNextTestSuite):
bo = make_blanket_order(blanket_order_type="Purchasing", supplier=supplier, item_code=item_code)
self.assertEqual(bo.items[0].party_item_code, "SUPP-PART-1")
def test_multicurrency_blanket_order(self):
company_currency = get_company_currency("_Test Company")
transaction_currency = "USD" if company_currency != "USD" else "EUR"
conversion_rate = 80
rate = 5
for blanket_order_type, target_doctypes in (
("Selling", ("Sales Order", "Quotation")),
("Purchasing", ("Purchase Order",)),
):
blanket_order = make_blanket_order(
blanket_order_type=blanket_order_type,
currency=transaction_currency,
conversion_rate=conversion_rate,
rate=rate,
)
self.assertEqual(blanket_order.currency, transaction_currency)
self.assertEqual(blanket_order.conversion_rate, conversion_rate)
self.assertEqual(blanket_order.items[0].base_rate, rate * conversion_rate)
for target_doctype in target_doctypes:
with self.subTest(target_doctype=target_doctype):
frappe.flags.args.doctype = target_doctype
target = make_order(blanket_order.name)
self.assertEqual(target.currency, transaction_currency)
self.assertEqual(target.conversion_rate, conversion_rate)
self.assertEqual(target.items[0].rate, rate)
self.assertEqual(target.items[0].base_rate, rate * conversion_rate)
self.assertEqual(target.items[0].blanket_order_rate, rate)
self.assertEqual(target.items[0].blanket_order, blanket_order.name)
def test_price_list_rates_and_mapping(self):
company = "_Test Company"
company_currency = get_company_currency(company)
transaction_currency = "USD" if company_currency != "USD" else "EUR"
conversion_rate = 80
price_list_rate = 800
for blanket_order_type, price_list_field, target_doctypes in (
("Selling", "selling_price_list", ("Sales Order", "Quotation")),
("Purchasing", "buying_price_list", ("Purchase Order",)),
):
blanket_order, price_list = make_priced_blanket_order(
blanket_order_type=blanket_order_type,
company=company,
currency=transaction_currency,
conversion_rate=conversion_rate,
price_list_rate=price_list_rate,
qty=1000,
)
blanket_order.insert()
blanket_order.submit()
expected_rate = price_list_rate / conversion_rate
self.assertEqual(blanket_order.price_list_currency, company_currency)
self.assertEqual(blanket_order.plc_conversion_rate, 1)
self.assertEqual(blanket_order.items[0].price_list_rate, expected_rate)
self.assertEqual(blanket_order.items[0].base_price_list_rate, price_list_rate)
self.assertEqual(blanket_order.items[0].rate, expected_rate)
self.assertEqual(blanket_order.items[0].base_rate, price_list_rate)
for target_doctype in target_doctypes:
with self.subTest(target_doctype=target_doctype):
frappe.flags.args.doctype = target_doctype
target = make_order(blanket_order.name)
self.assertEqual(target.get(price_list_field), price_list)
self.assertEqual(target.price_list_currency, company_currency)
self.assertEqual(target.plc_conversion_rate, 1)
self.assertEqual(target.items[0].price_list_rate, expected_rate)
self.assertEqual(target.items[0].base_price_list_rate, price_list_rate)
self.assertEqual(target.items[0].rate, expected_rate)
self.assertEqual(target.items[0].blanket_order, blanket_order.name)
def test_applying_price_list_ignores_empty_item_rows(self):
blanket_order = frappe.new_doc("Blanket Order")
blanket_order.blanket_order_type = "Selling"
blanket_order.company = "_Test Company"
blanket_order.customer = "_Test Customer"
blanket_order.from_date = today()
blanket_order.append("items", {})
pricing = apply_price_list(blanket_order.as_dict())
self.assertEqual(pricing["children"], [])
def test_price_list_rate_is_fetched_on_item_selection(self):
company = "_Test Company"
company_currency = get_company_currency(company)
price_list_rate = 800
blanket_order, _price_list = make_priced_blanket_order(
company=company,
currency=company_currency,
conversion_rate=1,
price_list_rate=price_list_rate,
qty=0,
)
item = blanket_order.items[0]
self.assertEqual(item.price_list_rate, price_list_rate)
self.assertEqual(item.rate, price_list_rate)
def test_price_list_rates_fetch_item_prices_once(self):
blanket_order = new_blanket_order("Selling")
blanket_order.selling_price_list = "_Test Price List"
for item_code in ("ITEM-1", "ITEM-2"):
blanket_order.append("items", {"item_code": item_code, "qty": 1})
with patch.object(
blanket_order_pricing, "get_item_prices_for_stock_uom", return_value={}
) as get_item_prices:
rates = blanket_order_pricing.get_price_list_rates(blanket_order)
self.assertEqual(len(rates), 2)
get_item_prices.assert_called_once_with(
frappe._dict(
{
"price_list": "_Test Price List",
"customer": "_Test Customer",
"supplier": None,
"transaction_date": blanket_order.from_date,
}
),
["ITEM-1", "ITEM-2"],
)
def test_price_list_conversion_uses_currency_precision(self):
company = "_Test Company"
company_currency = get_company_currency(company)
transaction_currency = "USD" if company_currency != "USD" else "EUR"
conversion_rate = 95.47
price_list_rate = 100
blanket_order, _price_list = make_priced_blanket_order(
company=company,
currency=transaction_currency,
conversion_rate=conversion_rate,
price_list_rate=price_list_rate,
)
item = blanket_order.items[0]
expected_rate = flt(price_list_rate / conversion_rate, item.precision("rate"))
expected_base_rate = flt(expected_rate * conversion_rate, item.precision("base_rate"))
self.assertFalse(frappe.get_meta("Blanket Order Item").get_field("rate").precision)
self.assertEqual(item.price_list_rate, expected_rate)
self.assertEqual(item.base_price_list_rate, expected_base_rate)
self.assertEqual(item.rate, expected_rate)
self.assertEqual(item.base_rate, expected_base_rate)
blanket_order.insert()
blanket_order.submit()
frappe.flags.args.doctype = "Sales Order"
sales_order = make_order(blanket_order.name)
sales_order.delivery_date = today()
sales_order.insert()
self.assertEqual(sales_order.items[0].price_list_rate, item.price_list_rate)
self.assertEqual(sales_order.items[0].base_price_list_rate, item.base_price_list_rate)
self.assertEqual(sales_order.items[0].rate, item.rate)
self.assertEqual(sales_order.items[0].base_rate, item.base_rate)
def test_applying_price_list_can_reset_conversion_rate(self):
company_currency = get_company_currency("_Test Company")
transaction_currency = "USD" if company_currency != "USD" else "EUR"
blanket_order, _price_list = make_priced_blanket_order(
currency=transaction_currency,
conversion_rate=80,
price_list_rate=100,
)
with patch(
"erpnext.manufacturing.doctype.blanket_order.blanket_order_pricing.get_exchange_rate",
return_value=95.47,
):
pricing = apply_price_list(blanket_order.as_dict(), reset_conversion_rate=True)
self.assertEqual(pricing["parent"]["conversion_rate"], 95.47)
expected_rate = flt(
100 / pricing["parent"]["conversion_rate"],
blanket_order.items[0].precision("rate"),
)
expected_base_rate = flt(
expected_rate * pricing["parent"]["conversion_rate"],
blanket_order.items[0].precision("base_rate"),
)
self.assertEqual(pricing["children"][0]["base_rate"], expected_base_rate)
def test_blanket_order_lookup_filters_currency(self):
company_currency = get_company_currency("_Test Company")
transaction_currency = "USD" if company_currency != "USD" else "EUR"
blanket_order = make_blanket_order(
blanket_order_type="Selling",
currency=transaction_currency,
conversion_rate=80,
)
filters = {
"company": blanket_order.company,
"currency": transaction_currency,
"blanket_order_type": "Selling",
"item": blanket_order.items[0].item_code,
}
matching_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertIn(blanket_order.name, [order[0] for order in matching_orders])
filters["currency"] = company_currency
other_currency_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
self.assertNotIn(blanket_order.name, [order[0] for order in other_currency_orders])
details = get_blanket_order_details(
{
"blanket_order": blanket_order.name,
"company": blanket_order.company,
"currency": company_currency,
"customer": blanket_order.customer,
"doctype": "Sales Order",
"item_code": blanket_order.items[0].item_code,
"transaction_date": today(),
}
)
self.assertFalse(details)
def make_blanket_order(**args):
args = frappe._dict(args)
bo = new_blanket_order(
blanket_order_type=args.blanket_order_type,
company=args.company or "_Test Company",
currency=args.currency,
conversion_rate=args.conversion_rate or 1,
customer=args.customer,
supplier=args.supplier,
)
bo = frappe.new_doc("Blanket Order")
bo.blanket_order_type = args.blanket_order_type
bo.company = args.company or "_Test Company"
if args.blanket_order_type == "Selling":
bo.customer = args.customer or "_Test Customer"
else:
bo.supplier = args.supplier or "_Test Supplier"
bo.from_date = today()
bo.to_date = add_months(bo.from_date, months=12)
bo.append(
"items",
{
@@ -391,84 +166,3 @@ def make_blanket_order(**args):
bo.insert()
bo.submit()
return bo
def make_priced_blanket_order(
blanket_order_type="Selling",
company="_Test Company",
currency=None,
conversion_rate=1,
price_list_rate=800,
qty=1,
):
price_list = make_blanket_order_price_list(get_company_currency(company), price_list_rate)
blanket_order = new_blanket_order(
blanket_order_type=blanket_order_type,
company=company,
currency=currency,
conversion_rate=conversion_rate,
)
config = blanket_order_pricing.get_order_type_config(blanket_order_type)
blanket_order.set(config["price_list_field"], price_list)
item = blanket_order.append("items", {"item_code": "_Test Item", "qty": qty, "rate": 0})
pricing = apply_price_list(blanket_order.as_dict())
blanket_order.update(pricing["parent"])
item.update({key: value for key, value in pricing["children"][0].items() if key != "name"})
return blanket_order, price_list
def new_blanket_order(
blanket_order_type,
company="_Test Company",
currency=None,
conversion_rate=1,
customer=None,
supplier=None,
):
blanket_order = frappe.new_doc("Blanket Order")
blanket_order.blanket_order_type = blanket_order_type
blanket_order.company = company
blanket_order.currency = currency or get_company_currency(company)
blanket_order.conversion_rate = conversion_rate
blanket_order.from_date = today()
blanket_order.to_date = add_months(blanket_order.from_date, months=12)
config = blanket_order_pricing.get_order_type_config(blanket_order_type)
party = customer if config["party_field"] == "customer" else supplier
blanket_order.set(config["party_field"], party or f"_Test {config['party_type']}")
return blanket_order
def make_blanket_order_price_list(currency, price_list_rate):
price_list = "_Test Blanket Order Price List"
if not frappe.db.exists("Price List", price_list):
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": price_list,
"currency": currency,
"selling": 1,
"buying": 1,
}
).insert()
else:
frappe.db.set_value("Price List", price_list, {"currency": currency, "selling": 1, "buying": 1})
item_price = frappe.db.get_value(
"Item Price", {"price_list": price_list, "item_code": "_Test Item"}, "name"
)
if item_price:
frappe.db.set_value("Item Price", item_price, "price_list_rate", price_list_rate)
else:
frappe.get_doc(
{
"doctype": "Item Price",
"price_list": price_list,
"item_code": "_Test Item",
"price_list_rate": price_list_rate,
}
).insert()
return price_list

View File

@@ -10,10 +10,7 @@
"party_item_code",
"column_break_3",
"qty",
"price_list_rate",
"base_price_list_rate",
"rate",
"base_rate",
"ordered_qty",
"section_break_7",
"terms_and_conditions"
@@ -44,37 +41,11 @@
"in_list_view": 1,
"label": "Quantity"
},
{
"fieldname": "price_list_rate",
"fieldtype": "Currency",
"label": "Price List Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "base_price_list_rate",
"fieldtype": "Currency",
"label": "Price List Rate (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"options": "currency",
"reqd": 1
},
{
"fieldname": "base_rate",
"fieldtype": "Currency",
"label": "Rate (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1,
"reqd": 1
},
{
@@ -103,7 +74,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-08-27 10:55:37.000000",
"modified": "2024-03-27 13:06:40.083042",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order Item",
@@ -114,4 +85,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -14,8 +14,6 @@ class BlanketOrderItem(Document):
if TYPE_CHECKING:
from frappe.types import DF
base_price_list_rate: DF.Currency
base_rate: DF.Currency
item_code: DF.Link
item_name: DF.Data | None
ordered_qty: DF.Float
@@ -23,7 +21,6 @@ class BlanketOrderItem(Document):
parentfield: DF.Data
parenttype: DF.Data
party_item_code: DF.Data | None
price_list_rate: DF.Currency
qty: DF.Float
rate: DF.Currency
terms_and_conditions: DF.Text | None

View File

@@ -685,9 +685,7 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
get_bom_material_detail(doc, cdt, cdn, secondary_items);
}
buying_price_list() {
const doc = this.frm.doc;
buying_price_list(doc) {
if (doc.rm_cost_as_per !== "Price List" && doc.buying_price_list) {
this.frm.set_value("buying_price_list", "");
return;
@@ -698,8 +696,8 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
}
}
plc_conversion_rate() {
if (!this.in_apply_price_list && this.frm.doc.rm_cost_as_per === "Price List") {
plc_conversion_rate(doc) {
if (!this.in_apply_price_list && doc.rm_cost_as_per === "Price List") {
this.apply_price_list(null, true);
}
}
@@ -755,7 +753,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
conversion_factor: d.conversion_factor,
sourced_by_supplier: d.sourced_by_supplier,
do_not_explode: d.do_not_explode,
source_warehouse: d.source_warehouse || doc.default_source_warehouse,
fetch_rate: !secondary_items,
},
callback: function (r) {
@@ -766,10 +763,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
doc = locals[doc.doctype][doc.name];
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
if (secondary_items && d.valuation_type === "Valuation Rate") {
erpnext.bom.fetch_secondary_item_cost(doc, cdt, cdn);
}
},
freeze: true,
});
@@ -783,10 +776,11 @@ cur_frm.cscript.qty = function (doc) {
cur_frm.cscript.rate = function (doc, cdt, cdn) {
var d = locals[cdt][cdn];
const is_secondary_item = cdt == "BOM Secondary Item";
if (d.bom_no) {
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
get_bom_material_detail(doc, cdt, cdn, false);
get_bom_material_detail(doc, cdt, cdn, is_secondary_item);
} else {
erpnext.bom.calculate_rm_cost(doc);
erpnext.bom.calculate_total(doc);
@@ -949,9 +943,6 @@ frappe.ui.form.on("BOM Item", {
do_not_explode: function (frm, cdt, cdn) {
get_bom_material_detail(frm.doc, cdt, cdn, false);
},
source_warehouse: function (frm, cdt, cdn) {
get_bom_material_detail(frm.doc, cdt, cdn, false);
},
});
frappe.ui.form.on("BOM Item", "qty", function (frm, cdt, cdn) {
@@ -1024,48 +1015,11 @@ frappe.tour["BOM"] = [
];
frappe.ui.form.on("BOM Secondary Item", {
valuation_type(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.valuation_type !== "% of FG Cost") {
frappe.model.set_value(cdt, cdn, "cost_allocation_per", 0);
}
if (row.valuation_type === "Valuation Rate") {
erpnext.bom.fetch_secondary_item_cost(frm.doc, cdt, cdn);
} else if (row.valuation_type !== "Manual") {
frappe.model.set_value(cdt, cdn, { cost: 0, base_cost: 0 });
}
item_code(frm, cdt, cdn) {
const { item_code } = locals[cdt][cdn];
},
});
erpnext.bom.fetch_secondary_item_cost = function (doc, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.item_code) return;
frappe.call({
doc: doc,
method: "get_bom_material_detail",
args: {
company: doc.company,
item_code: row.item_code,
uom: row.uom,
stock_uom: row.stock_uom,
conversion_factor: row.conversion_factor,
warehouse: doc.default_target_warehouse,
set_rate_based_on_warehouse: 1,
force_valuation_rate: 1,
fetch_rate: 1,
bom_no: "",
},
callback(r) {
const cost = flt(r.message.rate) * flt(row.stock_qty);
frappe.model.set_value(cdt, cdn, {
cost: cost,
base_cost: cost * flt(doc.conversion_rate || 1),
});
},
});
};
function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
frappe.prompt(
{

View File

@@ -392,7 +392,7 @@
{
"fetch_from": "item.description",
"fieldname": "description",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"label": "Item Description",
"read_only": 1
},
@@ -761,7 +761,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-08-23 15:20:11.032436",
"modified": "2026-04-17 15:22:33.598938",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",

View File

@@ -302,7 +302,7 @@ class BOM(WebsiteGenerator):
self.set_default_uom()
self.validate_semi_finished_goods()
self.validate_secondary_items()
self.validate_secondary_items_cost()
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
if self.docstatus == 1:
@@ -361,20 +361,8 @@ class BOM(WebsiteGenerator):
)
def validate_secondary_items(self):
seen_items = set()
for item in self.secondary_items:
# every consumer merges secondary rows by item and type, so duplicates cannot
# keep their own quantities, percentages or valuation mode
key = (item.item_code, item.secondary_item_type or "")
if key in seen_items:
frappe.throw(
_(
"Row #{0}: Item {1} is already added with the same Type in the Secondary Items table."
).format(item.idx, get_link_to_form("Item", item.item_code))
)
seen_items.add(key)
if item.valuation_type != "Valuation Rate" and item.item_code == self.item:
if not item.is_legacy and item.item_code == self.item:
frappe.throw(
_(
"Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
@@ -465,25 +453,13 @@ class BOM(WebsiteGenerator):
def set_fg_cost_allocation(self):
total_secondary_items_per = 0
own_cost = 0
for item in self.secondary_items:
if item.valuation_type in ("Valuation Rate", "Manual"):
item.cost_allocation_per = 0
own_cost += flt(item.cost)
total_secondary_items_per += item.cost_allocation_per
if self.cost_allocation_per == 100 and total_secondary_items_per:
self.cost_allocation_per -= total_secondary_items_per
self.cost_allocation = (self.raw_material_cost - own_cost) * (self.cost_allocation_per / 100)
def validate_secondary_items_cost(self):
if flt(self.secondary_items_cost) > flt(self.raw_material_cost):
frappe.throw(
_("The cost of the secondary items cannot exceed the raw material cost of {0}.").format(
frappe.bold(flt(self.raw_material_cost))
)
)
self.cost_allocation = self.raw_material_cost * (self.cost_allocation_per / 100)
def validate_total_cost_allocation(self):
total_cost_allocation_per = self.cost_allocation_per
@@ -554,7 +530,6 @@ class BOM(WebsiteGenerator):
"conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier,
"do_not_explode": item.do_not_explode,
"source_warehouse": item.source_warehouse or self.default_source_warehouse,
"fetch_rate": True,
}
)
@@ -640,12 +615,7 @@ class BOM(WebsiteGenerator):
if not self.rm_cost_as_per:
self.rm_cost_as_per = "Valuation Rate"
if arg and arg.get("force_valuation_rate"):
# Valuation Rate secondary items ignore the BOM's rm_cost_as_per method:
# bin-average valuation like the raw materials, scoped to the default
# target warehouse when set.
rate = get_valuation_rate(arg)
elif arg:
if arg:
# Customer Provided parts and Supplier sourced parts will have zero rate
if not frappe.db.get_value("Item", arg["item_code"], "is_customer_provided_item") and not arg.get(
"sourced_by_supplier"
@@ -980,7 +950,6 @@ class BOM(WebsiteGenerator):
self.calculate_op_cost(update_hour_rate)
self.calculate_rm_cost(save=save_updates)
self.calculate_secondary_items_costs(save=save_updates)
self.set_fg_cost_allocation()
if save_updates:
# not via doc event, table is not regenerated and needs updation
self.calculate_exploded_cost()
@@ -1067,7 +1036,6 @@ class BOM(WebsiteGenerator):
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
"is_phantom_item": d.is_phantom_item,
"source_warehouse": d.source_warehouse or self.default_source_warehouse,
},
notify=False,
)
@@ -1090,54 +1058,24 @@ class BOM(WebsiteGenerator):
self.base_raw_material_cost = base_total_rm_cost
def calculate_secondary_items_costs(self, save=False):
"""Valuation Rate and Manual rows carry their own cost, deducted from the raw
material cost; the % of FG Cost rows split the remainder by their percentage."""
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_sm_cost = 0
base_total_sm_cost = 0
precision = self.precision("raw_material_cost")
allocation_basis = flt(self.raw_material_cost) - self._set_own_cost_secondary_items(precision, save)
for d in self.get("secondary_items"):
if d.valuation_type not in ("Valuation Rate", "Manual"):
d.cost = flt(allocation_basis * (d.cost_allocation_per / 100), precision)
if not d.is_legacy:
d.cost = flt(self.raw_material_cost * (d.cost_allocation_per / 100), precision)
d.base_cost = flt(d.cost * self.conversion_rate, precision)
total_sm_cost += d.cost
base_total_sm_cost += d.base_cost
if save:
d.db_update()
total_sm_cost += d.cost
base_total_sm_cost += d.base_cost
self.secondary_items_cost = total_sm_cost
self.base_secondary_items_cost = base_total_sm_cost
def _set_own_cost_secondary_items(self, precision, save) -> float:
"""Cost of the rows valued on their own: fetched for Valuation Rate, kept for Manual."""
total = 0.0
for d in self.get("secondary_items"):
if d.valuation_type == "Valuation Rate":
rate = self.get_rm_rate(self._secondary_item_rate_args(d))
d.cost = flt(flt(rate) * flt(d.stock_qty), precision)
elif d.valuation_type == "Manual":
d.cost = flt(d.cost, precision)
else:
continue
d.base_cost = flt(d.cost * self.conversion_rate, precision)
total += d.cost
if save:
d.db_update()
return total
def _secondary_item_rate_args(self, d):
return {
"item_code": d.item_code,
"company": self.company,
"warehouse": self.default_target_warehouse,
"set_rate_based_on_warehouse": 1,
"force_valuation_rate": 1,
}
def calculate_exploded_cost(self):
"Set exploded row cost from it's parent BOM."
rm_rate_map = self.get_rm_rate_map()
@@ -1360,8 +1298,7 @@ class BOM(WebsiteGenerator):
def has_scrap_items(self):
return any(
d.get("secondary_item_type") == "Scrap" or d.get("valuation_type") == "Valuation Rate"
for d in self.get("secondary_items")
d.get("secondary_item_type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items")
)
@@ -1428,12 +1365,8 @@ def get_valuation_rate(data):
.where((bin_table.item_code == item_code) & (wh_table.company == company))
)
warehouse = data.get("source_warehouse")
if data.get("set_rate_based_on_warehouse") and data.get("warehouse"):
warehouse = data.get("warehouse")
if warehouse:
item_valuation = item_valuation.where(bin_table.warehouse == warehouse)
item_valuation = item_valuation.where(bin_table.warehouse == data.get("warehouse"))
item_valuation = item_valuation.run(as_dict=True)[0]
@@ -1482,7 +1415,7 @@ def get_bom_items_as_dict(
if fetch_secondary_items:
fetch_exploded = 0
group_by_cond = "group by item_code, secondary_item_type"
group_by_cond = "group by item_code"
# Did not use qty_consumed_per_unit in the query, as it leads to rounding loss
query = """select
@@ -1531,10 +1464,10 @@ def get_bom_items_as_dict(
query, {"parent": bom, "qty": qty, "bom": bom, "company": company}, as_dict=True
)
elif fetch_secondary_items:
query = query.format( # nosemgrep
query = query.format(
table="BOM Secondary Item",
where_conditions=")",
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.valuation_type, bom_item.cost / nullif(bom_item.stock_qty, 0) as manual_rate",
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.is_legacy",
is_stock_item=is_stock_item,
qty_field="stock_qty",
group_by_cond=group_by_cond,
@@ -1557,9 +1490,6 @@ def get_bom_items_as_dict(
for item in items:
key = item.item_code
if fetch_secondary_items:
key = (item.item_code, item.secondary_item_type or "")
if item.operation_row_id:
key = (item.item_code, item.operation_row_id)

View File

@@ -435,7 +435,7 @@ class TestBOM(ERPNextTestSuite):
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
bom_doc = create_bom_with_process_loss_item(
fg_item_non_whole, bom_item, scrap_qty=2, process_loss_percentage=110
fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0, process_loss_percentage=110
)
# PL can't be > 100
self.assertRaises(frappe.ValidationError, bom_doc.submit)
@@ -462,224 +462,12 @@ class TestBOM(ERPNextTestSuite):
"secondary_item_type": "Additional Finished Good",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
# FG item of the BOM cannot also be a secondary item
self.assertRaises(frappe.ValidationError, bom_doc.save)
@timeout
def test_duplicate_secondary_item_not_allowed(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 1,
"valuation_type": "Valuation Rate",
},
)
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
self.assertRaises(frappe.ValidationError, bom_doc.save)
# the same item with a different type is a distinct secondary output
bom_doc.secondary_items[1].secondary_item_type = "By-Product"
bom_doc.save()
@timeout
def test_secondary_item_manual_cost(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
by_product = make_item(properties={"is_stock_item": 1}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": by_product,
"secondary_item_type": "By-Product",
"qty": 1,
"valuation_type": "Manual",
"cost": 150,
},
)
bom_doc.save()
row = bom_doc.secondary_items[0]
self.assertEqual(row.cost, 150)
self.assertEqual(row.cost_allocation_per, 0)
self.assertEqual(bom_doc.secondary_items_cost, 150)
self.assertEqual(bom_doc.total_cost, 850)
self.assertEqual(bom_doc.cost_allocation, 850)
# a manual cost above the raw material cost would turn the finished good negative
bom_doc.secondary_items[0].cost = 1100
self.assertRaises(frappe.ValidationError, bom_doc.save)
@timeout
def test_secondary_item_valuation_rate_method(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
by_product = make_item(properties={"is_stock_item": 1}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.append(
"secondary_items",
{
"item_code": by_product,
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
},
)
bom_doc.save()
scrap_row = bom_doc.secondary_items[0]
self.assertEqual(scrap_row.cost, 100)
self.assertEqual(scrap_row.cost_allocation_per, 0)
# the by-product's percentage applies to the cost net of the valuation rate rows
self.assertEqual(bom_doc.raw_material_cost, 1000)
self.assertEqual(bom_doc.secondary_items[1].cost, 90)
self.assertEqual(bom_doc.cost_allocation_per, 90)
self.assertEqual(bom_doc.cost_allocation, 810)
self.assertEqual(bom_doc.secondary_items_cost, 190)
self.assertEqual(bom_doc.total_cost, 810)
@timeout
def test_rm_rate_scoped_to_source_warehouse(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
make_stock_entry(item_code=rm_item, target="_Test Warehouse 1 - _TC", qty=10, basic_rate=50)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "source_warehouse": "_Test Warehouse - _TC"})
bom_doc.save()
self.assertEqual(bom_doc.items[0].rate, 100)
bom_doc.items[0].source_warehouse = None
bom_doc.save()
self.assertEqual(bom_doc.items[0].rate, 75)
bom_doc.default_source_warehouse = "_Test Warehouse 1 - _TC"
bom_doc.save()
self.assertEqual(bom_doc.items[0].rate, 50)
@timeout
def test_secondary_item_rate_scoped_to_target_warehouse(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item_code=scrap_item, target="_Test Warehouse - _TC", qty=10, basic_rate=40)
make_stock_entry(item_code=scrap_item, target="_Test Warehouse 1 - _TC", qty=10, basic_rate=20)
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.default_target_warehouse = "_Test Warehouse - _TC"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 1,
"valuation_type": "Valuation Rate",
},
)
bom_doc.save()
self.assertEqual(bom_doc.secondary_items[0].cost, 40)
bom_doc.default_target_warehouse = None
bom_doc.save()
self.assertEqual(bom_doc.secondary_items[0].cost, 30)
@timeout
def test_secondary_item_valuation_rate_refreshed_on_update_cost(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 10, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": scrap_item,
"secondary_item_type": "Scrap",
"qty": 2,
"valuation_type": "Valuation Rate",
},
)
bom_doc.save()
bom_doc.submit()
frappe.db.set_value("Item", scrap_item, "valuation_rate", 80)
bom_doc.update_cost()
bom_doc.reload()
self.assertEqual(bom_doc.secondary_items[0].cost, 160)
self.assertEqual(bom_doc.total_cost, 840)
self.assertEqual(bom_doc.cost_allocation, 840)
@timeout
def test_bom_item_query(self):
query = partial(
@@ -1338,7 +1126,7 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
def create_bom_with_process_loss_item(
fg_item, bom_item, scrap_qty=0, fg_qty=2, process_loss_percentage=0, company=None
fg_item, bom_item, scrap_qty=0, scrap_rate=0, fg_qty=2, process_loss_percentage=0, company=None
):
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item.item_code
@@ -1360,11 +1148,11 @@ def create_bom_with_process_loss_item(
"secondary_items",
{
"item_code": fg_item.item_code,
"secondary_item_type": "Scrap",
"qty": scrap_qty,
"stock_qty": scrap_qty,
"uom": fg_item.stock_uom,
"stock_uom": fg_item.stock_uom,
"rate": scrap_rate,
},
)

View File

@@ -38,13 +38,15 @@
{
"secondary_items":[
{
"amount": 2000.0,
"doctype": "BOM Secondary Item",
"item_code": "_Test Item Home Desktop 100",
"parentfield": "secondary_items",
"stock_qty": 1.0,
"rate": 2000.0,
"stock_uom": "_Test UOM",
"secondary_item_type": "Scrap",
"valuation_type": "Valuation Rate"
"is_legacy": 1
}
],
"items": [

View File

@@ -91,10 +91,14 @@ class BOMCreator(Document):
key = (row.item_code, row.fg_reference_id)
if key in item_map:
parent_item_code = next(
item.item_code for item in self.items if item.name == row.fg_reference_id
)
frappe.throw(
_(
"Item {0} added multiple times under the same parent item {1} at rows {2} and {3}"
).format(bold(row.item_code), bold(row.fg_item), item_map[key], row.idx),
).format(bold(row.item_code), bold(parent_item_code), item_map[key], row.idx),
title=_("Duplicate Item Under Same Parent"),
)
else:

View File

@@ -6,10 +6,13 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"secondary_item_type",
"rate",
"column_break_gres",
"is_legacy",
"section_break_sbnk",
"item_code",
"item_name",
"secondary_item_type",
"uom",
"column_break_atlf",
"qty",
@@ -22,7 +25,6 @@
"column_break_wsra",
"image_nygv",
"section_break_ielf",
"valuation_type",
"cost_allocation_per",
"process_loss_per",
"column_break_gtbl",
@@ -32,12 +34,13 @@
],
"fields": [
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "secondary_item_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good",
"reqd": 1
"mandatory_depends_on": "eval:!doc.is_legacy",
"options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good"
},
{
"fieldname": "item_code",
@@ -60,9 +63,10 @@
"fieldname": "cost",
"fieldtype": "Currency",
"label": "Cost",
"no_copy": 1,
"non_negative": 1,
"options": "currency",
"read_only_depends_on": "eval:doc.valuation_type != 'Manual'",
"read_only": 1,
"reqd": 1
},
{
@@ -99,6 +103,7 @@
"reqd": 1
},
{
"depends_on": "eval:!doc.is_legacy",
"fieldname": "section_break_ielf",
"fieldtype": "Section Break"
},
@@ -138,7 +143,6 @@
},
{
"default": "0",
"depends_on": "eval:doc.valuation_type == '% of FG Cost'",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "Cost Allocation %",
@@ -171,20 +175,33 @@
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Cost (Company Currency)",
"no_copy": 1,
"non_negative": 1,
"options": "Company:company:default_currency",
"read_only": 1,
"reqd": 1
},
{
"default": "Valuation Rate",
"description": "Valuation Rate and Manual value this item on its own and deduct that cost from the raw material cost, like the pre-v16 scrap items. % of FG Cost allocates a percentage of the remaining raw material cost.",
"fieldname": "valuation_type",
"fieldtype": "Select",
"label": "Valuation Type",
"options": "Valuation Rate\n% of FG Cost\nManual",
"reqd": 1,
"show_description_on_click": 1
"fieldname": "column_break_gres",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "is_legacy",
"fieldname": "is_legacy",
"fieldtype": "Check",
"label": "Is Legacy",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:doc.is_legacy",
"fieldname": "rate",
"fieldtype": "Currency",
"label": "Rate",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
},
{
"default": "0",

View File

@@ -20,6 +20,7 @@ class BOMSecondaryItem(Document):
cost_allocation_per: DF.Percent
description: DF.TextEditor | None
image: DF.AttachImage | None
is_legacy: DF.Check
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data
@@ -28,11 +29,11 @@ class BOMSecondaryItem(Document):
process_loss_per: DF.Percent
process_loss_qty: DF.Float
qty: DF.Float
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
rate: DF.Currency
stock_qty: DF.Float
stock_uom: DF.Link | None
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
uom: DF.Link
valuation_type: DF.Literal["Valuation Rate", "% of FG Cost", "Manual"]
# end: auto-generated types
pass

View File

@@ -246,16 +246,15 @@ class JobCard(Document):
wo_qty = wo_qty + (wo_qty * over_production_percentage / 100)
job_card = frappe.qb.DocType("Job Card")
job_card_qty = (
frappe.qb.from_(job_card)
.select(Sum(job_card.for_quantity - IfNull(job_card.pending_qty, 0)))
.where(
(job_card.work_order == self.work_order)
& (job_card.operation_id == self.operation_id)
& (job_card.docstatus != 2)
)
.run()
job_card_qty = frappe.get_all(
"Job Card",
fields=[{"SUM": "for_quantity"}],
filters={
"work_order": self.work_order,
"operation_id": self.operation_id,
"docstatus": ["!=", 2],
},
as_list=1,
)
job_card_qty = flt(job_card_qty[0][0]) if job_card_qty else 0
@@ -293,10 +292,10 @@ class JobCard(Document):
fetch_exploded=0,
fetch_secondary_items=1,
)
for values in items_dict.values():
for item_code, values in items_dict.items():
values = frappe._dict(values)
secondary_item = {
"item_code": values.item_code,
"item_code": item_code,
"stock_qty": values.qty,
"item_name": values.item_name,
"stock_uom": values.stock_uom,
@@ -304,10 +303,11 @@ class JobCard(Document):
"bom_secondary_item": values.name,
}
secondary_item["stock_qty"] -= flt(
secondary_item["stock_qty"] * (flt(values.process_loss_per) / 100),
self.precision("for_quantity"),
)
if not values.is_legacy:
secondary_item["stock_qty"] -= flt(
secondary_item["stock_qty"] * (values.process_loss_per / 100),
self.precision("for_quantity"),
)
self.append("secondary_items", secondary_item)
@@ -1757,7 +1757,7 @@ class JobCard(Document):
ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order)
ste.stock_entry.set_secondary_items_from_job_card()
for row in ste.stock_entry.items:
if (row.secondary_item_type or row.valuation_type) and not row.t_warehouse:
if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse:
row.t_warehouse = self.target_warehouse
if auto_submit:

View File

@@ -20,7 +20,7 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
make_stock_entry as make_stock_entry_from_jc,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_job_card, make_work_order
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_work_order
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.patches.v16_0.set_stock_uom_in_job_card import execute as set_stock_uom_in_job_card
from erpnext.stock.doctype.item.test_item import create_item
@@ -318,43 +318,6 @@ class TestJobCard(ERPNextTestSuite):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
def test_material_request_stock_entry_uses_job_card_coverage(self):
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
mr = make_material_request(job_card.name)
mr.schedule_date = today()
for row in mr.items:
row.qty = flt(row.qty) / 2
row.stock_qty = flt(row.stock_qty) / 2
mr.submit()
stock_entry = make_stock_entry(mr.name)
self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
selected_row = mr.items[0]
try:
frappe.flags.selected_children = {"items": [selected_row.name]}
selected_stock_entry = make_stock_entry(mr.name)
finally:
frappe.flags.selected_children = None
self.assertEqual(
[row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
)
self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
for row in mr.items:
transferred_qty = flt(row.stock_qty) / 2
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
mr.reload()
repeated_stock_entry = make_stock_entry(mr.name)
self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
@@ -768,7 +731,6 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
def test_job_card_material_transfer_via_pick_list(self):
from erpnext.stock.doctype.material_request.material_request import create_pick_list
@@ -1341,7 +1303,6 @@ class TestJobCard(ERPNextTestSuite):
"qty": 1,
"process_loss_per": 10,
"cost_allocation_per": 5,
"valuation_type": "% of FG Cost",
"secondary_item_type": "Scrap",
},
)
@@ -1861,16 +1822,16 @@ class TestJobCard(ERPNextTestSuite):
job_card.save()
job_card.complete_job_card(
qty=2,
qty=3,
for_quantity=5,
pending_qty=3,
pending_qty=2,
process_loss_qty=0,
end_time="2024-04-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.for_quantity), 5)
self.assertEqual(flt(job_card.pending_qty), 3)
self.assertEqual(flt(job_card.pending_qty), 2)
self.assertEqual(flt(job_card.process_loss_qty), 0)
job_card.submit()
@@ -1878,29 +1839,13 @@ class TestJobCard(ERPNextTestSuite):
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item)
self.assertEqual(flt(finished_item.qty), 2)
self.assertEqual(flt(finished_item.qty), 3)
manufacturing_entry.submit()
job_card.reload()
self.assertEqual(flt(job_card.manufactured_qty), 2)
self.assertEqual(flt(job_card.manufactured_qty), 3)
self.assertEqual(job_card.status, "Completed")
make_job_card(
work_order.name,
[
{
"name": work_order.operations[0].name,
"operation": "Pending Qty Op A",
"qty": 3,
"pending_qty": 3,
}
],
)
follow_up_job_card = frappe.get_last_doc(
"Job Card", {"work_order": work_order.name, "operation_id": work_order.operations[0].name}
)
self.assertEqual(flt(follow_up_job_card.for_quantity), 3)
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -2649,98 +2594,6 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(s.items[3].item_code, "_Test Item")
self.assertEqual(s.items[3].transfer_qty, 2)
frappe.db.set_value(
"Stock Entry Detail",
s.items[3].name,
{"secondary_item_type": None, "valuation_type": "Valuation Rate"},
)
stock_entry = frappe.get_doc({"doctype": "Stock Entry", "work_order": self.work_order.name})
used_secondary_items = stock_entry.get_used_secondary_items()
self.assertEqual(used_secondary_items[("_Test Item", "Scrap")], 2)
def test_secondary_items_from_multiple_boms_stay_separate(self):
"""Rows linked to different BOM rows keep their own quantity and valuation mode."""
from erpnext.stock.doctype.item.test_item import make_item
secondary_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name
bom_links = []
for cost_allocation_per in (0, 10):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
bom_doc = frappe.new_doc("BOM")
bom_doc.item = fg_item
bom_doc.quantity = 1
bom_doc.company = "_Test Company"
bom_doc.currency = "INR"
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "rate": 100.0})
bom_doc.append(
"secondary_items",
{
"item_code": secondary_item,
"secondary_item_type": "Scrap",
"qty": 1,
"cost_allocation_per": cost_allocation_per,
"valuation_type": "% of FG Cost" if cost_allocation_per else "Valuation Rate",
},
)
bom_doc.save()
bom_doc.submit()
bom_links.append(bom_doc.secondary_items[0].name)
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:
make_stock_entry(
item_code=row.item_code,
target="_Test Warehouse - _TC",
qty=10,
basic_rate=100,
)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
job_card.append(
"secondary_items",
{
"item_code": secondary_item,
"stock_qty": 2,
"secondary_item_type": "Scrap",
"bom_secondary_item": bom_links[0],
},
)
job_card.append(
"secondary_items",
{
"item_code": secondary_item,
"stock_qty": 3,
"secondary_item_type": "Scrap",
"bom_secondary_item": bom_links[1],
},
)
job_card.append(
"time_logs",
{
"from_time": "2009-01-01 12:06:25",
"to_time": "2009-01-01 12:37:25",
"completed_qty": job_card.for_quantity,
},
)
job_card.save()
job_card.submit()
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
s = frappe.get_doc(make_stock_entry_for_wo(self.work_order.name, "Manufacture"))
rows = {d.bom_secondary_item: d for d in s.items if d.item_code == secondary_item}
self.assertEqual(len(rows), 2)
self.assertEqual(rows[bom_links[0]].qty, 2)
self.assertEqual(rows[bom_links[0]].valuation_type, "Valuation Rate")
self.assertEqual(rows[bom_links[1]].qty, 3)
self.assertEqual(rows[bom_links[1]].valuation_type, "% of FG Cost")
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"overproduction_percentage_for_work_order": 100}
)

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