Merge pull request #55547 from frappe/version-16-hotfix

This commit is contained in:
Mihir Kandoi
2026-06-02 22:23:38 +05:30
committed by GitHub
114 changed files with 441520 additions and 374283 deletions

52
.github/helper/merge_po_files.py vendored Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Overlay develop's .po translations onto hotfix's .po files.
Called by sync_hotfix_translations.sh before `bench update-po-files`.
Merge rules:
a. msgid absent from develop → keep hotfix's existing msgstr
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
c. msgid present in both → use develop's msgstr
"""
from datetime import datetime, timezone
from pathlib import Path
from babel.messages.pofile import read_po, write_po
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
LOCALE = Path("./apps/erpnext/erpnext/locale/")
added = updated = 0
for src in sorted(DEVELOP.glob("*.po")):
dst = LOCALE / src.name
with src.open("rb") as f:
dev = read_po(f)
if not dst.exists():
dev.revision_date = datetime.now(timezone.utc)
with dst.open("wb") as f:
write_po(f, dev)
added += 1
print(f" [new] {src.name}")
continue
with dst.open("rb") as f:
hf = read_po(f)
changes = 0
for msg in hf:
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
msg.string = dev[msg.id].string
changes += 1
if changes:
hf.revision_date = datetime.now(timezone.utc)
with dst.open("wb") as f:
write_po(f, hf)
updated += 1
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
else:
print(f" [no-op] {src.name}")
print(f"\n{added} new language(s), {updated} updated.")

View File

@@ -0,0 +1,121 @@
#!/bin/bash
# Syncs Crowdin translations from develop to a hotfix branch.
# Merge logic: see merge_po_files.py.
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
# (all set by Actions).
set -e
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
cd ~ || exit
echo "=== Setting up bench ==="
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
cd ./frappe-bench || exit
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
cd "./apps/${APP_NAME}" || exit
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
gh auth setup-git
git fetch upstream "${HOTFIX_BRANCH}"
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
else
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
fi
cd ../.. || exit
echo "=== Fetching develop's .po files ==="
mkdir -p /tmp/develop-po
git -C "${GITHUB_WORKSPACE}" fetch origin develop
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
| tar -xf - -C /tmp/develop-po/
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
if [ "${po_count}" -eq 0 ]; then
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
exit 1
fi
echo "Extracted ${po_count} .po file(s) from develop."
echo "=== Merging and reconciling ==="
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
bench update-po-files --app "${APP_NAME}"
cd "./apps/${APP_NAME}" || exit
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
echo "Translations are already up to date. No PR needed."
exit 0
fi
echo "Changed files:"
git diff --name-only "${APP_NAME}/locale/"
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
echo "=== Committing ==="
while IFS= read -r file; do
git add "${file}"
lang=$(basename "${file}" .po)
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
while IFS= read -r file; do
git add "${file}"
if ! git diff --staged --quiet -- "${file}"; then
lang=$(basename "${file}" .po)
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
else
git restore --staged -- "${file}"
fi
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
fi
git push -u upstream sync_translations_${HOTFIX_BRANCH}
echo "=== Opening PR (if not already open) ==="
existing_pr=$(gh pr list \
--base "${HOTFIX_BRANCH}" \
--head "sync_translations_${HOTFIX_BRANCH}" \
--state open \
--json number \
--jq 'length' \
-R "${GITHUB_REPOSITORY}")
if [ "${existing_pr}" -gt 0 ]; then
echo "PR already open — branch updated in place. No new PR needed."
exit 0
fi
gh pr create \
--base "${HOTFIX_BRANCH}" \
--head "sync_translations_${HOTFIX_BRANCH}" \
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
| Case | Condition | Result |
|------|-----------|--------|
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
Generated by the \`sync-hotfix-translations\` workflow." \
--label "translation" \
--label "skip-release-notes" \
--reviewer "${PR_REVIEWER}" \
-R "${GITHUB_REPOSITORY}"

View File

@@ -0,0 +1,70 @@
name: Build and Upload Assets
on:
push:
branches:
- develop
- 'version-*'
concurrency:
group: build-assets-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
build-assets:
name: Build JS/CSS and upload to release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: frappe/frappe
path: apps/frappe
ref: ${{ github.ref_name }}
- uses: actions/checkout@v4
with:
path: apps/erpnext
- name: Create bench structure
run: |
mkdir -p sites
printf "frappe\nerpnext\n" > sites/apps.txt
- uses: actions/setup-node@v4
with:
node-version: 24
cache: yarn
cache-dependency-path: apps/frappe/yarn.lock
- name: Install frappe JS dependencies
working-directory: apps/frappe
run: yarn install --frozen-lockfile
- name: Install erpnext JS dependencies
working-directory: apps/erpnext
run: yarn install --frozen-lockfile --ignore-scripts
- name: Link node_modules into public/
working-directory: apps/frappe
run: ln -s "$PWD/node_modules" frappe/public/node_modules
- name: Build assets (production)
working-directory: apps/frappe
run: yarn run production
- name: Package assets
working-directory: apps/erpnext
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
- name: Upload to rolling release
working-directory: apps/erpnext
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="assets-${GITHUB_REF_NAME//\//-}"
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
gh release upload "$TAG" erpnext-assets.tar.gz --clobber

View File

@@ -0,0 +1,52 @@
# Runner — maintain this file on each hotfix branch, not on develop.
#
# Fires when main.pot changes on this branch (i.e. after a POT update PR
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
#
# Uses github.ref_name so the file is identical across all hotfix branches
# with no branch-specific edits required.
name: Run hotfix translation sync
on:
workflow_dispatch:
# One run at a time per branch. cancel-in-progress: false to avoid leaving
# an orphaned remote branch from a mid-flight git push + gh pr create.
concurrency:
group: sync-hotfix-translations-${{ github.ref_name }}
cancel-in-progress: false
jobs:
sync-translations:
name: Sync translations from develop into ${{ github.ref_name }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
HOTFIX_BRANCH: ${{ github.ref_name }}
APP_NAME: ${{ github.event.repository.name }}
steps:
- name: Checkout ${{ env.HOTFIX_BRANCH }}
uses: actions/checkout@v6
with:
ref: ${{ env.HOTFIX_BRANCH }}
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run sync script
run: |
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
PR_REVIEWER: diptanilsaha

View File

@@ -0,0 +1,39 @@
# Orchestrator — lives on develop only.
#
# Triggers on the weekly schedule and dispatches the runner workflow on each
# hotfix branch listed in the matrix. To add or remove a branch, edit the
# matrix below.
#
# POT-change triggers are handled by the runner on each hotfix branch
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
# from the branch that receives the push.
name: Sync translations to hotfix branches
on:
schedule:
# 10:00 UTC Monday
- cron: "0 10 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
trigger-runners:
name: Trigger sync → ${{ matrix.hotfix_branch }}
runs-on: ubuntu-latest
strategy:
matrix:
hotfix_branch:
- version-16-hotfix
fail-fast: false
steps:
- name: Dispatch runner on ${{ matrix.hotfix_branch }}
run: |
gh workflow run run-hotfix-translation-sync.yml \
--repo "${{ github.repository }}" \
--ref "${{ matrix.hotfix_branch }}"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -518,6 +518,7 @@ def get_account_autoname(account_number, account_name, company):
def update_account_number(name, account_name, account_number=None, from_descendant=False): def update_account_number(name, account_name, account_number=None, from_descendant=False):
_ensure_idle_system() _ensure_idle_system()
account = frappe.get_cached_doc("Account", name) account = frappe.get_cached_doc("Account", name)
account.check_permission("write")
if not account: if not account:
return return

View File

@@ -1,7 +1,6 @@
{ {
"actions": [], "actions": [],
"autoname": "format:Bank Statement Import on {creation}", "autoname": "format:Bank Statement Import on {creation}",
"beta": 1,
"creation": "2019-08-04 14:16:08.318714", "creation": "2019-08-04 14:16:08.318714",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
@@ -226,7 +225,7 @@
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
"links": [], "links": [],
"modified": "2025-06-11 02:23:22.159961", "modified": "2026-05-30 20:51:10.353723",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Bank Statement Import", "name": "Bank Statement Import",

View File

@@ -2,7 +2,6 @@
"actions": [], "actions": [],
"allow_events_in_timeline": 1, "allow_events_in_timeline": 1,
"autoname": "naming_series:", "autoname": "naming_series:",
"beta": 1,
"creation": "2019-07-05 16:34:31.013238", "creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType", "doctype": "DocType",
"engine": "InnoDB", "engine": "InnoDB",
@@ -400,7 +399,7 @@
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-11-26 13:46:07.760867", "modified": "2026-05-30 23:18:04.712528",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Dunning", "name": "Dunning",
@@ -449,6 +448,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "ASC", "sort_order": "ASC",
"states": [], "states": [],

View File

@@ -1,7 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1, "allow_rename": 1,
"beta": 1,
"creation": "2019-12-04 04:59:08.003664", "creation": "2019-12-04 04:59:08.003664",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
@@ -107,7 +106,7 @@
"link_fieldname": "dunning_type" "link_fieldname": "dunning_type"
} }
], ],
"modified": "2024-03-27 13:08:19.584112", "modified": "2026-05-30 23:18:20.740726",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Dunning Type", "name": "Dunning Type",
@@ -151,6 +150,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -565,18 +565,19 @@ class FinancialQueryBuilder:
frappe.qb.from_(acb_table) frappe.qb.from_(acb_table)
.select( .select(
acb_table.account, acb_table.account,
(acb_table.debit - acb_table.credit).as_("balance"), Sum(acb_table.debit - acb_table.credit).as_("balance"),
) )
.where(acb_table.company == self.company) .where(acb_table.company == self.company)
.where(acb_table.account.isin(account_names)) .where(acb_table.account.isin(account_names))
.where(acb_table.period_closing_voucher == closing_voucher) .where(acb_table.period_closing_voucher == closing_voucher)
.groupby(acb_table.account)
) )
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance") query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
results = self._execute_with_permissions(query, "Account Closing Balance") results = self._execute_with_permissions(query, "Account Closing Balance")
for row in results: for row in results:
closing_balances[row["account"]] = row["balance"] closing_balances[row["account"]] = row["balance"] or 0.0
return closing_balances return closing_balances

View File

@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.financial_report_template.test_financial_report_te
) )
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
from erpnext.tests.utils import change_settings
class TestDependencyResolver(FinancialReportTemplateTestCase): class TestDependencyResolver(FinancialReportTemplateTestCase):
@@ -1953,6 +1954,104 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
jv_2023.cancel() jv_2023.cancel()
@change_settings("Accounts Settings", {"use_legacy_controller_for_pcv": 1})
def test_opening_balance_sums_acb_rows_across_dimensions(self):
"""
Account Closing Balance stores one row per (account, cost_center,
project, finance_book). The closing-balance fetch must sum all rows.
"""
company = "_Test Company"
cash_account = "_Test Cash - _TC"
sales_account = "Sales - _TC"
cc_1 = "_Test Cost Center - _TC"
cc_2 = "_Test Cost Center 2 - _TC"
docs = []
try:
jv_2023_cc1 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=3000,
posting_date="2023-06-15",
cost_center=cc_1,
company=company,
submit=True,
)
docs.append(jv_2023_cc1)
jv_2023_cc2 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=2000,
posting_date="2023-06-15",
cost_center=cc_2,
company=company,
submit=True,
)
docs.append(jv_2023_cc2)
fy_2023 = get_fiscal_year("2023-06-15", company=company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": "2023-12-31",
"period_start_date": fy_2023[1],
"period_end_date": fy_2023[2],
"company": company,
"fiscal_year": fy_2023[0],
"cost_center": cc_1,
"closing_account_head": "Deferred Revenue - _TC",
"remarks": "Test multi-dim PCV",
}
)
pcv.insert()
pcv.submit()
docs.append(pcv)
jv_2024 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=100,
posting_date="2024-01-15",
cost_center=cc_1,
company=company,
submit=True,
)
docs.append(jv_2024)
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-03-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True,
}
periods = [
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account must appear in results")
jan_cash = cash_data.get_period("2024_jan")
self.assertEqual(jan_cash.opening, 5000.0)
self.assertEqual(jan_cash.movement, 100.0)
self.assertEqual(jan_cash.closing, 5100.0)
finally:
self.cancel_docs(docs)
def test_opening_entries_roll_into_opening_after_period_closing(self): def test_opening_entries_roll_into_opening_after_period_closing(self):
""" """
Sequence: Sequence:

View File

@@ -9,6 +9,14 @@ from erpnext.tests.utils import ERPNextTestSuite
class FinancialReportTemplateTestCase(ERPNextTestSuite): class FinancialReportTemplateTestCase(ERPNextTestSuite):
"""Utility class with common setup and helper methods for all test classes""" """Utility class with common setup and helper methods for all test classes"""
def cancel_docs(self, docs):
"""Cancel submitted docs in reverse creation order to avoid dependency issues."""
for doc in reversed(docs):
if doc:
doc.reload()
if doc.docstatus == 1:
doc.cancel()
def setUp(self): def setUp(self):
"""Set up test data""" """Set up test data"""
self.create_test_template() self.create_test_template()

View File

@@ -433,15 +433,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
accounts_add(doc, cdt, cdn) { accounts_add(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn); var row = frappe.get_doc(cdt, cdn);
row.exchange_rate = 1; if (!row.exchange_rate) row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) { if (!row.account) {
if (d.account && d.party && d.party_type) { $.each(doc.accounts, function (i, d) {
row.account = d.account; if (d.account && d.party && d.party_type) {
row.party = d.party; row.account = d.account;
row.party_type = d.party_type; row.party = d.party;
row.exchange_rate = d.exchange_rate; row.party_type = d.party_type;
} row.exchange_rate = d.exchange_rate;
}); }
});
}
// set difference // set difference
if (doc.difference) { if (doc.difference) {

View File

@@ -1,7 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_copy": 1, "allow_copy": 1,
"beta": 1,
"creation": "2017-08-29 02:22:54.947711", "creation": "2017-08-29 02:22:54.947711",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
@@ -90,7 +89,7 @@
"hide_toolbar": 1, "hide_toolbar": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-03-31 01:47:20.360352", "modified": "2026-05-30 23:18:48.691227",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Opening Invoice Creation Tool", "name": "Opening Invoice Creation Tool",

View File

@@ -1726,6 +1726,35 @@ frappe.ui.form.on("Payment Entry", {
}, },
}); });
}, },
before_cancel: function (frm) {
return new Promise((resolve, reject) => {
frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
args: { payment_entry: frm.doc.name },
callback: function (r) {
const linked = r.message || [];
if (!linked.length) {
resolve();
return;
}
const bt_links = linked
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
.join(", ");
frappe.confirm(
__(
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
[bt_links]
),
() => resolve(),
() => reject(),
__("Yes"),
__("No")
);
},
});
});
},
}); });
frappe.ui.form.on("Payment Entry Reference", { frappe.ui.form.on("Payment Entry Reference", {

View File

@@ -3568,3 +3568,16 @@ def make_payment_order(source_name, target_doc=None):
@erpnext.allow_regional @erpnext.allow_regional
def add_regional_gl_entries(gl_entries, doc): def add_regional_gl_entries(gl_entries, doc):
return return
@frappe.whitelist()
def get_linked_bank_transactions(payment_entry: str) -> list:
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
return frappe.get_all(
"Bank Transaction Payments",
filters={
"payment_document": "Payment Entry",
"payment_entry": payment_entry,
},
pluck="parent",
)

View File

@@ -26,8 +26,6 @@
"due_date", "due_date",
"amended_from", "amended_from",
"return_against", "return_against",
"section_break_abck",
"title",
"accounting_dimensions_section", "accounting_dimensions_section",
"project", "project",
"dimension_col_break", "dimension_col_break",
@@ -172,6 +170,7 @@
"is_discounted", "is_discounted",
"col_break23", "col_break23",
"status", "status",
"title",
"more_info", "more_info",
"debit_to", "debit_to",
"party_account_currency", "party_account_currency",
@@ -1625,10 +1624,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Auto Repeat" "label": "Auto Repeat"
}, },
{
"fieldname": "section_break_abck",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -1641,7 +1636,7 @@
"icon": "fa fa-file-text", "icon": "fa fa-file-text",
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-05-01 02:37:30.580568", "modified": "2026-05-28 12:22:50.253090",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "POS Invoice", "name": "POS Invoice",

View File

@@ -208,15 +208,14 @@ class POSProfile(Document):
def set_defaults(self, include_current_pos=True): def set_defaults(self, include_current_pos=True):
frappe.defaults.clear_default("is_pos") frappe.defaults.clear_default("is_pos")
if not include_current_pos: pfu = frappe.qb.DocType("POS Profile User")
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
else:
condition = " where pfu.default = 1 "
pos_view_users = frappe.db.sql_list( query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
f"""select pfu.user
from `tabPOS Profile User` as pfu {condition}""" if not include_current_pos:
) query = query.where(pfu.name != self.name)
pos_view_users = query.run(as_list=1, pluck=True)
for user in pos_view_users: for user in pos_view_users:
if user: if user:

View File

@@ -151,13 +151,13 @@
"label": "Default Advance Account", "label": "Default Advance Account",
"mandatory_depends_on": "doc.party_type", "mandatory_depends_on": "doc.party_type",
"options": "Account", "options": "Account",
"reqd": 1 "reqd": 0
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-01-08 08:22:14.798085", "modified": "2026-05-16 11:43:12.758685",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Process Payment Reconciliation", "name": "Process Payment Reconciliation",

View File

@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
bank_cash_account: DF.Link | None bank_cash_account: DF.Link | None
company: DF.Link company: DF.Link
cost_center: DF.Link | None cost_center: DF.Link | None
default_advance_account: DF.Link default_advance_account: DF.Link | None
error_log: DF.LongText | None error_log: DF.LongText | None
from_invoice_date: DF.Date | None from_invoice_date: DF.Date | None
from_payment_date: DF.Date | None from_payment_date: DF.Date | None
@@ -218,10 +218,7 @@ def trigger_reconciliation_for_queued_docs():
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"] fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
def get_filters_as_tuple(fields, doc): def get_filters_as_tuple(fields, doc):
filters = () return tuple(doc.get(x) or "" for x in fields)
for x in fields:
filters += tuple(doc.get(x))
return filters
for x in all_queued: for x in all_queued:
doc = frappe.get_doc("Process Payment Reconciliation", x) doc = frappe.get_doc("Process Payment Reconciliation", x)

View File

@@ -27,8 +27,6 @@
"update_billed_amount_in_purchase_receipt", "update_billed_amount_in_purchase_receipt",
"apply_tds", "apply_tds",
"amended_from", "amended_from",
"section_break_hzux",
"title",
"supplier_invoice_details", "supplier_invoice_details",
"bill_no", "bill_no",
"column_break_15", "column_break_15",
@@ -201,6 +199,7 @@
"hold_comment", "hold_comment",
"additional_info_section", "additional_info_section",
"is_internal_supplier", "is_internal_supplier",
"title",
"represents_company", "represents_company",
"supplier_group", "supplier_group",
"sender", "sender",
@@ -1685,10 +1684,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Automation" "label": "Automation"
}, },
{
"fieldname": "section_break_hzux",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -1703,7 +1698,7 @@
"idx": 204, "idx": 204,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-04-28 07:15:31.062404", "modified": "2026-05-28 12:36:55.215363",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice", "name": "Purchase Invoice",

View File

@@ -33,8 +33,6 @@
"is_created_using_pos", "is_created_using_pos",
"pos_closing_entry", "pos_closing_entry",
"has_subcontracted", "has_subcontracted",
"section_break_qllv",
"title",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -234,6 +232,7 @@
"status", "status",
"remarks", "remarks",
"customer_group", "customer_group",
"title",
"column_break_imbx", "column_break_imbx",
"is_internal_customer", "is_internal_customer",
"represents_company", "represents_company",
@@ -2343,10 +2342,6 @@
"fieldname": "column_break_iaso", "fieldname": "column_break_iaso",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "section_break_qllv",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -2367,7 +2362,7 @@
"link_fieldname": "consolidated_invoice" "link_fieldname": "consolidated_invoice"
} }
], ],
"modified": "2026-05-21 17:31:11.190958", "modified": "2026-05-28 12:15:12.486443",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice", "name": "Sales Invoice",

View File

@@ -3036,15 +3036,22 @@ def update_multi_mode_option(doc, pos_profile):
def get_all_mode_of_payments(doc): def get_all_mode_of_payments(doc):
return frappe.db.sql( ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
""" ModeOfPayment = frappe.qb.DocType("Mode of Payment")
select mpa.default_account, mpa.parent, mp.type as type
from `tabMode of Payment Account` mpa,`tabMode of Payment` mp query = (
where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""", frappe.qb.from_(ModeOfPaymentAccount)
{"company": doc.company}, .join(ModeOfPayment)
as_dict=1, .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == doc.company)
.where(ModeOfPayment.enabled == 1)
) )
return query.run(as_dict=1)
def get_mode_of_payments_info(mode_of_payments, company): def get_mode_of_payments_info(mode_of_payments, company):
data = frappe.db.sql( data = frappe.db.sql(

View File

@@ -947,7 +947,8 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Distributed Discount Amount", "label": "Distributed Discount Amount",
"options": "currency", "options": "currency",
"print_hide": 1 "print_hide": 1,
"read_only": 1
}, },
{ {
"fieldname": "available_quantity_section", "fieldname": "available_quantity_section",
@@ -1016,7 +1017,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-02-24 14:37:16.853941", "modified": "2026-05-29 12:23:28.259905",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice Item", "name": "Sales Invoice Item",

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
from frappe.utils import getdate from frappe.utils import cstr, getdate
from erpnext import allow_regional from erpnext import allow_regional
from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.accounts_controller import validate_account_head
@@ -48,7 +48,7 @@ class TaxWithholdingCategory(Document):
for d in self.get("rates"): for d in self.get("rates"):
if getdate(d.from_date) >= getdate(d.to_date): if getdate(d.from_date) >= getdate(d.to_date):
frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx)) frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx))
group_rates[d.tax_withholding_group].append(d) group_rates[cstr(d.tax_withholding_group)].append(d)
# Validate overlapping dates within each group # Validate overlapping dates within each group
for group, rates in group_rates.items(): for group, rates in group_rates.items():
@@ -92,10 +92,9 @@ class TaxWithholdingCategory(Document):
def get_applicable_tax_row(self, posting_date, tax_withholding_group): def get_applicable_tax_row(self, posting_date, tax_withholding_group):
for row in self.rates: for row in self.rates:
if ( if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) row.tax_withholding_group
and row.tax_withholding_group == tax_withholding_group ) == cstr(tax_withholding_group):
):
return row return row
frappe.throw(_("No Tax Withholding data found for the current posting date.")) frappe.throw(_("No Tax Withholding data found for the current posting date."))
@@ -116,7 +115,7 @@ class TaxWithholdingDetails:
def __init__( def __init__(
self, self,
tax_withholding_categories: list[str], tax_withholding_categories: list[str],
tax_withholding_group: str, tax_withholding_group: str | None,
posting_date: str, posting_date: str,
party_type: str, party_type: str,
party: str, party: str,

View File

@@ -999,6 +999,47 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
self.cleanup_invoices(invoices) self.cleanup_invoices(invoices)
def test_null_and_empty_tax_withholding_group_are_equivalent(self):
"""
NULL and empty-string `tax_withholding_group` must be treated as the
same value.
"""
category = frappe.get_doc("Tax Withholding Category", "Cumulative Threshold TDS")
original_row = category.rates[0]
original_row.tax_withholding_group = None
# Part 1: validate_dates must detect overlap between NULL-group and
# empty-string-group rows covering the same date range.
category.append(
"rates",
{
"from_date": original_row.from_date,
"to_date": original_row.to_date,
"tax_withholding_group": "",
"tax_withholding_rate": original_row.tax_withholding_rate,
},
)
with self.assertRaises(frappe.ValidationError):
category.validate_dates()
category.rates.pop()
# Part 2: get_applicable_tax_row must match NULL <-> "" in either direction.
posting_date = original_row.from_date
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="")
self.assertEqual(row.name, original_row.name)
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
self.assertEqual(row.name, original_row.name)
original_row.tax_withholding_group = ""
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
self.assertEqual(row.name, original_row.name)
original_row.tax_withholding_group = None
with self.assertRaises(frappe.ValidationError):
category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="194R")
def test_tds_calculation_on_net_total(self): def test_tds_calculation_on_net_total(self):
self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS") self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS")
invoices = [] invoices = []

View File

@@ -4,14 +4,14 @@
"docstatus": 0, "docstatus": 0,
"doctype": "Number Card", "doctype": "Number Card",
"document_type": "Purchase Invoice", "document_type": "Purchase Invoice",
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]", "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum", "function": "Sum",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"label": "Total Incoming Bills", "label": "Total Incoming Bills",
"modified": "2024-12-05 12:00:00.000000", "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Total Incoming Bills", "name": "Total Incoming Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0, "docstatus": 0,
"doctype": "Number Card", "doctype": "Number Card",
"document_type": "Payment Entry", "document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]", "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"function": "Sum", "function": "Sum",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"label": "Total Incoming Payment", "label": "Total Incoming Payment",
"modified": "2024-12-05 12:00:00.000000", "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Total Incoming Payment", "name": "Total Incoming Payment",

View File

@@ -4,14 +4,14 @@
"docstatus": 0, "docstatus": 0,
"doctype": "Number Card", "doctype": "Number Card",
"document_type": "Sales Invoice", "document_type": "Sales Invoice",
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]", "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum", "function": "Sum",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"label": "Total Outgoing Bills", "label": "Total Outgoing Bills",
"modified": "2024-12-05 12:00:00.000000", "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Total Outgoing Bills", "name": "Total Outgoing Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0, "docstatus": 0,
"doctype": "Number Card", "doctype": "Number Card",
"document_type": "Payment Entry", "document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]", "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"function": "Sum", "function": "Sum",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"label": "Total Outgoing Payment", "label": "Total Outgoing Payment",
"modified": "2024-12-05 12:00:00.000000", "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Total Outgoing Payment", "name": "Total Outgoing Payment",

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.query_builder import Order from frappe.query_builder import Order
from frappe.query_builder.functions import Max, Min from frappe.query_builder.functions import Max, Min
from frappe.utils import ( from frappe.utils import (
DateTimeLikeObject,
add_months, add_months,
cint, cint,
flt, flt,
@@ -359,7 +360,8 @@ def get_message_for_depr_entry_posting_error(asset_links, error_log_links):
@frappe.whitelist() @frappe.whitelist()
def scrap_asset(asset_name, scrap_date=None): def scrap_asset(asset_name: str, scrap_date: DateTimeLikeObject | None = None):
frappe.has_permission("Asset", "write", asset_name, throw=True)
asset = frappe.get_doc("Asset", asset_name) asset = frappe.get_doc("Asset", asset_name)
scrap_date = getdate(scrap_date) or getdate(today()) scrap_date = getdate(scrap_date) or getdate(today())
asset.db_set("disposal_date", scrap_date) asset.db_set("disposal_date", scrap_date)
@@ -448,7 +450,8 @@ def create_journal_entry_for_scrap(asset, scrap_date):
@frappe.whitelist() @frappe.whitelist()
def restore_asset(asset_name): def restore_asset(asset_name: str):
frappe.has_permission("Asset", "write", asset_name, throw=True)
asset = frappe.get_doc("Asset", asset_name) asset = frappe.get_doc("Asset", asset_name)
reverse_depreciation_entry_made_on_disposal(asset) reverse_depreciation_entry_made_on_disposal(asset)
reset_depreciation_schedule(asset, get_note_for_restore(asset)) reset_depreciation_schedule(asset, get_note_for_restore(asset))

View File

@@ -17,6 +17,7 @@
"section_break_vwgg", "section_break_vwgg",
"maintain_same_rate", "maintain_same_rate",
"column_break_lwxs", "column_break_lwxs",
"set_landed_cost_based_on_purchase_invoice_rate",
"maintain_same_rate_action", "maintain_same_rate_action",
"role_to_override_stop_action", "role_to_override_stop_action",
"transaction_settings_section", "transaction_settings_section",
@@ -24,7 +25,8 @@
"po_required", "po_required",
"pr_required", "pr_required",
"project_update_frequency", "project_update_frequency",
"column_break_12", "over_order_allowance",
"column_break_kdcm",
"allow_multiple_items", "allow_multiple_items",
"allow_negative_rates_for_items", "allow_negative_rates_for_items",
"set_valuation_rate_for_rejected_materials", "set_valuation_rate_for_rejected_materials",
@@ -33,7 +35,6 @@
"purchase_invoice_settings_section", "purchase_invoice_settings_section",
"bill_for_rejected_quantity_in_purchase_invoice", "bill_for_rejected_quantity_in_purchase_invoice",
"use_transaction_date_exchange_rate", "use_transaction_date_exchange_rate",
"set_landed_cost_based_on_purchase_invoice_rate",
"zero_quantity_line_items_section", "zero_quantity_line_items_section",
"allow_zero_qty_in_supplier_quotation", "allow_zero_qty_in_supplier_quotation",
"allow_zero_qty_in_request_for_quotation", "allow_zero_qty_in_request_for_quotation",
@@ -156,10 +157,6 @@
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Transaction Settings" "label": "Transaction Settings"
}, },
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{ {
"default": "0", "default": "0",
"description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.", "description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.",
@@ -335,6 +332,16 @@
"hidden": 1, "hidden": 1,
"is_virtual": 1, "is_virtual": 1,
"label": "Naming Series options" "label": "Naming Series options"
},
{
"description": "The percentage by which you are allowed to order more on a Purchase Order than the quantity requested on the originating Material Request. For example, if the Material Request has 100 units and the allowance is 10%, you can order up to 110 units",
"fieldname": "over_order_allowance",
"fieldtype": "Float",
"label": "Over Order Allowance (%)"
},
{
"fieldname": "column_break_kdcm",
"fieldtype": "Column Break"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -343,7 +350,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-05-05 16:30:37.184607", "modified": "2026-05-27 23:04:00.842393",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Buying Settings", "name": "Buying Settings",

View File

@@ -34,6 +34,7 @@ class BuyingSettings(Document):
fixed_email: DF.Link | None fixed_email: DF.Link | None
maintain_same_rate: DF.Check maintain_same_rate: DF.Check
maintain_same_rate_action: DF.Literal["Stop", "Warn"] maintain_same_rate_action: DF.Literal["Stop", "Warn"]
over_order_allowance: DF.Float
over_transfer_allowance: DF.Float over_transfer_allowance: DF.Float
po_required: DF.Literal["No", "Yes"] po_required: DF.Literal["No", "Yes"]
pr_required: DF.Literal["No", "Yes"] pr_required: DF.Literal["No", "Yes"]

View File

@@ -23,8 +23,6 @@
"is_subcontracted", "is_subcontracted",
"has_unit_price_items", "has_unit_price_items",
"supplier_warehouse", "supplier_warehouse",
"section_break_ahub",
"title",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -56,9 +54,7 @@
"net_total", "net_total",
"section_break_48", "section_break_48",
"pricing_rules", "pricing_rules",
"raw_material_details",
"set_reserve_warehouse", "set_reserve_warehouse",
"supplied_items",
"taxes_section", "taxes_section",
"tax_category", "tax_category",
"taxes_and_charges", "taxes_and_charges",
@@ -156,6 +152,7 @@
"auto_repeat", "auto_repeat",
"update_auto_repeat_reference", "update_auto_repeat_reference",
"additional_info_section", "additional_info_section",
"title",
"party_account_currency", "party_account_currency",
"represents_company", "represents_company",
"ref_sq", "ref_sq",
@@ -1312,10 +1309,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Auto Repeat" "label": "Auto Repeat"
}, },
{
"fieldname": "section_break_ahub",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -1330,7 +1323,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-04-28 06:11:46.904768", "modified": "2026-05-28 12:34:19.659621",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order", "name": "Purchase Order",

View File

@@ -182,6 +182,9 @@ class PurchaseOrder(BuyingController):
"target_ref_field": "stock_qty", "target_ref_field": "stock_qty",
"source_field": "stock_qty", "source_field": "stock_qty",
"percent_join_field": "material_request", "percent_join_field": "material_request",
"global_allowance_field": "over_order_allowance",
"global_allowance_doctype": "Buying Settings",
"item_allowance_field": "over_order_allowance",
} }
] ]

View File

@@ -128,6 +128,44 @@ class TestPurchaseOrder(ERPNextTestSuite):
frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0) frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0)
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0) frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0)
def test_over_order_allowance_against_material_request(self) -> None:
"""Over Order Allowance in Buying Settings must govern PO qty vs MR qty independently
from Over Delivery/Receipt Allowance which governs receipt/delivery against a PO."""
mr = make_material_request(qty=100)
po = make_purchase_order(mr.name)
po.supplier = "_Test Supplier"
po.items[0].qty = 110 # 10% over the MR qty
# Without any allowance, submitting should raise an OverAllowanceError
from erpnext.controllers.status_updater import OverAllowanceError
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
self.assertRaises(OverAllowanceError, po.submit)
# Granting 10% in Over Order Allowance (Buying Settings) must allow the submit
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10)
po.reload()
po.items[0].qty = 110
po.submit()
self.assertEqual(po.docstatus, 1)
po.cancel()
# Over Delivery/Receipt Allowance must remain independent — changing it must not
# affect the MR → PO validation when Over Order Allowance is 0.
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50)
mr2 = make_material_request(qty=100)
po2 = make_purchase_order(mr2.name)
po2.supplier = "_Test Supplier"
po2.items[0].qty = 110
self.assertRaises(OverAllowanceError, po2.submit)
# cleanup
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
def test_update_remove_child_linked_to_mr(self): def test_update_remove_child_linked_to_mr(self):
"""Test impact on linked PO and MR on deleting/updating row.""" """Test impact on linked PO and MR on deleting/updating row."""
mr = make_material_request(qty=10) mr = make_material_request(qty=10)

View File

@@ -16,8 +16,6 @@
"status", "status",
"has_unit_price_items", "has_unit_price_items",
"amended_from", "amended_from",
"section_break_mhyw",
"title",
"suppliers_section", "suppliers_section",
"suppliers", "suppliers",
"items_section", "items_section",
@@ -44,6 +42,7 @@
"letter_head", "letter_head",
"more_info", "more_info",
"opportunity", "opportunity",
"title",
"address_and_contact_tab", "address_and_contact_tab",
"billing_address", "billing_address",
"billing_address_display", "billing_address_display",
@@ -374,10 +373,6 @@
"label": "Shipping Address Details", "label": "Shipping Address Details",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "section_break_mhyw",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -392,7 +387,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-04-28 06:18:05.661710", "modified": "2026-05-28 12:28:46.606963",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Request for Quotation", "name": "Request for Quotation",

View File

@@ -6,6 +6,7 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.core.doctype.communication.email import make from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments from frappe.desk.form.load import get_attachments
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
@@ -275,12 +276,20 @@ class RequestforQuotation(BuyingController):
supplier_doc.save() supplier_doc.save()
def create_user(self, rfq_supplier, link): def create_user(self, rfq_supplier, link):
contact_name = None
if rfq_supplier.contact:
name_fields = frappe.get_value(
"Contact", rfq_supplier.contact, ["first_name", "middle_name", "last_name"]
)
if name_fields:
contact_name = get_full_name(*name_fields)
user = frappe.get_doc( user = frappe.get_doc(
{ {
"doctype": "User", "doctype": "User",
"send_welcome_email": 0, "send_welcome_email": 0,
"email": rfq_supplier.email_id, "email": rfq_supplier.email_id,
"first_name": rfq_supplier.supplier_name or rfq_supplier.supplier, "first_name": contact_name or rfq_supplier.supplier_name or rfq_supplier.supplier,
"user_type": "Website User", "user_type": "Website User",
"redirect_url": link, "redirect_url": link,
} }

View File

@@ -20,8 +20,6 @@
"quotation_number", "quotation_number",
"has_unit_price_items", "has_unit_price_items",
"amended_from", "amended_from",
"section_break_kumc",
"title",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -118,6 +116,7 @@
"more_info", "more_info",
"is_subcontracted", "is_subcontracted",
"column_break_57", "column_break_57",
"title",
"opportunity", "opportunity",
"connections_tab" "connections_tab"
], ],
@@ -940,10 +939,6 @@
"fieldname": "auto_repeat_section", "fieldname": "auto_repeat_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Auto Repeat" "label": "Auto Repeat"
},
{
"fieldname": "section_break_kumc",
"fieldtype": "Section Break"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -952,7 +947,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-04-28 06:23:52.813948", "modified": "2026-05-28 12:29:37.509487",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Supplier Quotation", "name": "Supplier Quotation",

View File

@@ -390,10 +390,15 @@ def get_delivery_notes_to_be_billed(
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0)) .where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
) )
query = frappe.qb.get_query(
"Delivery Note",
fields=fields,
filters=filters,
ignore_permissions=False,
)
query = ( query = (
frappe.qb.from_(DeliveryNote) query.where(
.select(*[DeliveryNote[f] for f in fields])
.where(
(DeliveryNote.docstatus == 1) (DeliveryNote.docstatus == 1)
& (DeliveryNote.status.notin(["Stopped", "Closed"])) & (DeliveryNote.status.notin(["Stopped", "Closed"]))
& (DeliveryNote[searchfield].like(f"%{txt}%")) & (DeliveryNote[searchfield].like(f"%{txt}%"))
@@ -407,12 +412,11 @@ def get_delivery_notes_to_be_billed(
) )
) )
) )
.orderby(DeliveryNote[searchfield], order=Order.asc)
.limit(page_len)
.offset(start)
) )
if filters and isinstance(filters, dict):
for key, value in filters.items():
query = query.where(DeliveryNote[key] == value)
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
return query.run(as_dict=as_dict) return query.run(as_dict=as_dict)

View File

@@ -262,15 +262,17 @@ class StatusUpdater(Document):
def validate_qty(self): def validate_qty(self):
"""Validates qty at row level""" """Validates qty at row level"""
self.item_allowance = {}
self.global_qty_allowance = None
self.global_amount_allowance = None
for args in self.status_updater: for args in self.status_updater:
if "target_ref_field" not in args or args.get("validate_qty") is False: if "target_ref_field" not in args or args.get("validate_qty") is False:
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation # if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
continue continue
# Reset per-args so each config block uses its own allowance source without
# leaking cached values from a previous config block.
self.item_allowance = {}
self.global_qty_allowance = None
self.global_amount_allowance = None
items_to_validate = [] items_to_validate = []
selling_negative_rate_allowed = frappe.get_single_value( selling_negative_rate_allowed = frappe.get_single_value(
"Selling Settings", "allow_negative_rates_for_items" "Selling Settings", "allow_negative_rates_for_items"
@@ -402,9 +404,12 @@ class StatusUpdater(Document):
def check_overflow_with_allowance(self, item, args): def check_overflow_with_allowance(self, item, args):
""" """
Checks if there is overflow condering a relaxation allowance Checks if there is overflow considering a relaxation allowance.
""" """
qty_or_amount = "qty" if "qty" in args["target_ref_field"] else "amount" qty_or_amount = "qty" if "qty" in args["target_ref_field"] else "amount"
global_qty_allowance_field = args.get("global_allowance_field", "over_delivery_receipt_allowance")
global_qty_allowance_doctype = args.get("global_allowance_doctype", "Stock Settings")
item_qty_allowance_field = args.get("item_allowance_field", "over_delivery_receipt_allowance")
# check if overflow is within allowance # check if overflow is within allowance
( (
@@ -419,6 +424,9 @@ class StatusUpdater(Document):
self.global_qty_allowance, self.global_qty_allowance,
self.global_amount_allowance, self.global_amount_allowance,
qty_or_amount, qty_or_amount,
global_qty_allowance_field,
global_qty_allowance_doctype,
item_qty_allowance_field,
) )
if args["source_dt"] != "Pick List Item" if args["source_dt"] != "Pick List Item"
else (0, {}, None, None) else (0, {}, None, None)
@@ -463,7 +471,9 @@ class StatusUpdater(Document):
"Quotation Item", "Quotation Item",
"Packed Item", "Packed Item",
]: ]:
if qty_or_amount == "qty": if args.get("target_dt") == "Material Request Item":
action_msg = _('To allow over ordering, update "Over Order Allowance" in Buying Settings.')
elif qty_or_amount == "qty":
action_msg = _( action_msg = _(
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.' 'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
) )
@@ -724,16 +734,28 @@ class StatusUpdater(Document):
ref_doc.set_status(update=True) ref_doc.set_status(update=True)
@frappe.request_cache
def get_allowance_for( def get_allowance_for(
item_code, item_code,
item_allowance=None, item_allowance=None,
global_qty_allowance=None, global_qty_allowance=None,
global_amount_allowance=None, global_amount_allowance=None,
qty_or_amount="qty", qty_or_amount="qty",
global_qty_allowance_field="over_delivery_receipt_allowance",
global_qty_allowance_doctype="Stock Settings",
item_qty_allowance_field="over_delivery_receipt_allowance",
): ):
""" """
Returns the allowance for the item, if not set, returns global allowance Returns the allowance for the item, if not set, returns global allowance.
Args:
item_code: The item to get allowance for.
item_allowance: Cached per-item allowances from a previous call.
global_qty_allowance: Cached global qty allowance from a previous call.
global_amount_allowance: Cached global amount allowance from a previous call.
qty_or_amount: Whether to return qty or amount allowance.
global_qty_allowance_field: The field name on the settings doctype to use for the global qty allowance.
global_qty_allowance_doctype: The settings doctype to read the global qty allowance from.
item_qty_allowance_field: The field name on the Item doctype to use for the item-level qty allowance override.
""" """
if item_allowance is None: if item_allowance is None:
item_allowance = {} item_allowance = {}
@@ -755,13 +777,13 @@ def get_allowance_for(
) )
qty_allowance, over_billing_allowance = frappe.get_cached_value( qty_allowance, over_billing_allowance = frappe.get_cached_value(
"Item", item_code, ["over_delivery_receipt_allowance", "over_billing_allowance"] "Item", item_code, [item_qty_allowance_field, "over_billing_allowance"]
) )
if qty_or_amount == "qty" and not qty_allowance: if qty_or_amount == "qty" and not qty_allowance:
if global_qty_allowance is None: if global_qty_allowance is None:
global_qty_allowance = flt( global_qty_allowance = flt(
frappe.get_cached_value("Stock Settings", None, "over_delivery_receipt_allowance") frappe.get_single_value(global_qty_allowance_doctype, global_qty_allowance_field)
) )
qty_allowance = global_qty_allowance qty_allowance = global_qty_allowance
elif qty_or_amount == "amount" and not over_billing_allowance: elif qty_or_amount == "amount" and not over_billing_allowance:

View File

@@ -2112,7 +2112,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
@frappe.whitelist() @frappe.whitelist()
def check_item_quality_inspection(doctype, items): def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
if isinstance(items, str): if isinstance(items, str):
items = json.loads(items) items = json.loads(items)
@@ -2124,13 +2124,30 @@ def check_item_quality_inspection(doctype, items):
"Delivery Note": "inspection_required_before_delivery", "Delivery Note": "inspection_required_before_delivery",
} }
items_to_remove = [] inspection_fieldname = inspection_fieldname_map.get(doctype)
for item in items: if inspection_fieldname is None:
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)): return []
items_to_remove.append(item)
items = [item for item in items if item not in items_to_remove]
return items allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
)
if allow_after_transaction:
return items
item_codes = list({item.get("item_code") for item in items})
Item = frappe.qb.DocType("Item")
results = (
frappe.qb.from_(Item)
.select(Item.name)
.where((Item.name.isin(item_codes)) & (Item[inspection_fieldname] == 1))
.run(as_dict=True)
)
inspection_required_items = {row.name for row in results}
return [item for item in items if item.get("item_code") in inspection_required_items]
@frappe.whitelist() @frappe.whitelist()

View File

@@ -7,6 +7,7 @@ from collections import defaultdict
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, get_link_to_form from frappe.utils import cint, flt, get_link_to_form
@@ -1568,7 +1569,13 @@ def make_return_stock_entry_for_subcontract(
@frappe.whitelist() @frappe.whitelist()
def get_materials_from_supplier(subcontract_order, rm_details, order_doctype="Subcontracting Order"): def get_materials_from_supplier(source_name: str, target_doc: Document | str | None = None):
args = frappe.flags.args or {}
subcontract_order = args.get("subcontract_order") or source_name
rm_details = args.get("rm_details")
order_doctype = args.get("order_doctype") or "Subcontracting Order"
if isinstance(rm_details, str): if isinstance(rm_details, str):
rm_details = json.loads(rm_details) rm_details = json.loads(rm_details)

View File

@@ -347,7 +347,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db() sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5) self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items]) frappe.flags.args = frappe._dict(
subcontract_order=sco.name,
rm_details=[d.name for d in sco.supplied_items],
order_doctype=sco.doctype,
)
doc = get_materials_from_supplier(sco.name)
doc.save() doc.save()
self.assertEqual(doc.items[0].qty, 1) self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC") self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
@@ -404,7 +409,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db() sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5) self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items]) frappe.flags.args = frappe._dict(
subcontract_order=sco.name,
rm_details=[d.name for d in sco.supplied_items],
order_doctype=sco.doctype,
)
doc = get_materials_from_supplier(sco.name)
self.assertEqual(doc.items[0].qty, 1) self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC") self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC") self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC")
@@ -1133,7 +1143,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db() sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5) self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items]) frappe.flags.args = frappe._dict(
subcontract_order=sco.name,
rm_details=[d.name for d in sco.supplied_items],
order_doctype=sco.doctype,
)
doc = get_materials_from_supplier(sco.name)
self.assertEqual(doc.items[0].qty, 1) self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC") self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC") self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

59482
erpnext/locale/ko.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -583,7 +583,7 @@ frappe.ui.form.on("BOM", {
}, },
routing(frm) { routing(frm) {
if (frm.doc.routing) { if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
frappe.call({ frappe.call({
doc: frm.doc, doc: frm.doc,
method: "get_routing", method: "get_routing",

View File

@@ -1,5 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_bulk_edit": 1,
"creation": "2013-02-22 01:27:49", "creation": "2013-02-22 01:27:49",
"doctype": "DocType", "doctype": "DocType",
"document_type": "Setup", "document_type": "Setup",
@@ -139,11 +140,13 @@
"label": "Image" "label": "Image"
}, },
{ {
"default": "1",
"fetch_from": "operation.batch_size", "fetch_from": "operation.batch_size",
"fetch_if_empty": 1, "fetch_if_empty": 1,
"fieldname": "batch_size", "fieldname": "batch_size",
"fieldtype": "Int", "fieldtype": "Float",
"label": "Batch Size" "label": "Batch Size",
"non_negative": 1
}, },
{ {
"depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing", "depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing",
@@ -304,7 +307,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-04-01 17:09:48.771834", "modified": "2026-05-25 17:15:42.044630",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Operation", "name": "BOM Operation",

View File

@@ -18,7 +18,7 @@ class BOMOperation(Document):
base_cost_per_unit: DF.Float base_cost_per_unit: DF.Float
base_hour_rate: DF.Currency base_hour_rate: DF.Currency
base_operating_cost: DF.Currency base_operating_cost: DF.Currency
batch_size: DF.Int batch_size: DF.Float
bom_no: DF.Link | None bom_no: DF.Link | None
cost_per_unit: DF.Float cost_per_unit: DF.Float
description: DF.TextEditor | None description: DF.TextEditor | None

View File

@@ -176,7 +176,7 @@ class JobCard(Document):
self.validate_semi_finished_goods() self.validate_semi_finished_goods()
def validate_semi_finished_goods(self): def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods: if not self.track_semi_finished_goods or self.is_subcontracted:
return return
if self.items and not self.transferred_qty and not self.skip_material_transfer: if self.items and not self.transferred_qty and not self.skip_material_transfer:
@@ -1578,9 +1578,7 @@ def make_subcontracting_po(source_name, target_doc=None):
"Job Card", "Job Card",
source_name, source_name,
{ {
"Job Card": { "Job Card": {"doctype": "Purchase Order", "field_no_map": ["naming_series"]},
"doctype": "Purchase Order",
},
}, },
target_doc, target_doc,
set_missing_values, set_missing_values,

View File

@@ -4223,6 +4223,7 @@ class TestWorkOrder(ERPNextTestSuite):
"operations", "operations",
{ {
"operation": fg_operation.name, "operation": fg_operation.name,
"batch_size": fg_operation.batch_size,
"time_in_mins": 60, "time_in_mins": 60,
"workstation": workstation.name, "workstation": workstation.name,
}, },
@@ -4231,6 +4232,7 @@ class TestWorkOrder(ERPNextTestSuite):
fg_bom.items[0].bom_no = subassembly_bom.name fg_bom.items[0].bom_no = subassembly_bom.name
fg_bom.save() fg_bom.save()
fg_bom.submit() fg_bom.submit()
self.assertEqual(fg_bom.operations[0].batch_size, 25)
wo_order = make_wo_order_test_record( wo_order = make_wo_order_test_record(
item=fg_item.name, item=fg_item.name,

View File

@@ -202,7 +202,7 @@ class WorkOrder(Document):
self.calculate_operating_cost() self.calculate_operating_cost()
self.validate_qty() self.validate_qty()
self.validate_transfer_against() self.validate_transfer_against()
self.validate_operation_time() self.validate_operations()
self.status = self.get_status() self.status = self.get_status()
self.validate_workstation_type() self.validate_workstation_type()
self.reset_use_multi_level_bom() self.reset_use_multi_level_bom()
@@ -1499,8 +1499,11 @@ class WorkOrder(Document):
title=_("Missing value"), title=_("Missing value"),
) )
def validate_operation_time(self): def validate_operations(self):
for d in self.operations: for d in self.operations:
if not d.batch_size or d.batch_size <= 0:
d.batch_size = 1
if d.time_in_mins <= 0: if d.time_in_mins <= 0:
frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation)) frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))

View File

@@ -196,10 +196,11 @@
"read_only": 1 "read_only": 1
}, },
{ {
"default": "1",
"fieldname": "batch_size", "fieldname": "batch_size",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Batch Size", "label": "Batch Size",
"read_only": 1 "non_negative": 1
}, },
{ {
"fieldname": "sequence_id", "fieldname": "sequence_id",
@@ -316,7 +317,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-05-20 13:01:21.827200", "modified": "2026-05-25 17:15:12.038470",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Work Order Operation", "name": "Work Order Operation",

View File

@@ -2929,10 +2929,28 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
method: "erpnext.controllers.stock_controller.check_item_quality_inspection", method: "erpnext.controllers.stock_controller.check_item_quality_inspection",
args: { args: {
doctype: this.frm.doc.doctype, doctype: this.frm.doc.doctype,
docstatus: this.frm.doc.docstatus,
items: this.frm.doc.items, items: this.frm.doc.items,
}, },
freeze: true, freeze: true,
callback: function (r) { callback: function (r) {
if (r.message.length == 0) {
let type = inspection_type === "Incoming" ? "Purchase" : "Delivery";
let fieldname =
inspection_type === "Incoming"
? "Inspection Required before Purchase"
: "Inspection Required before Delivery";
frappe.msgprint({
title: __("Quality Inspection Not Configured"),
message: __(`Enable <b>{0}</b> on the Item master to proceed with {1} inspection.`, [
fieldname,
type,
]),
});
return;
}
r.message.forEach((item) => { r.message.forEach((item) => {
if (me.has_inspection_required(item)) { if (me.has_inspection_required(item)) {
let dialog_items = dialog.fields_dict.items; let dialog_items = dialog.fields_dict.items;

View File

@@ -22,8 +22,6 @@
"company", "company",
"has_unit_price_items", "has_unit_price_items",
"amended_from", "amended_from",
"section_break_jdzz",
"title",
"currency_and_price_list", "currency_and_price_list",
"currency", "currency",
"conversion_rate", "conversion_rate",
@@ -135,6 +133,7 @@
"status", "status",
"customer_group", "customer_group",
"territory", "territory",
"title",
"column_break_108", "column_break_108",
"opportunity", "opportunity",
"enq_det", "enq_det",
@@ -304,7 +303,6 @@
"read_only": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:(doc.quotation_to=='Customer' && doc.party_name)",
"fieldname": "col_break98", "fieldname": "col_break98",
"fieldtype": "Column Break", "fieldtype": "Column Break",
"width": "50%" "width": "50%"
@@ -1125,10 +1123,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Auto Repeat" "label": "Auto Repeat"
}, },
{
"fieldname": "section_break_jdzz",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -1142,7 +1136,7 @@
"idx": 82, "idx": 82,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-04-28 06:28:13.103302", "modified": "2026-05-30 17:40:02.667637",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Quotation", "name": "Quotation",

View File

@@ -26,8 +26,6 @@
"has_unit_price_items", "has_unit_price_items",
"is_subcontracted", "is_subcontracted",
"amended_from", "amended_from",
"section_break_zstt",
"title",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -176,6 +174,7 @@
"po_no", "po_no",
"po_date", "po_date",
"represents_company", "represents_company",
"title",
"column_break_yvzv", "column_break_yvzv",
"inter_company_order_reference", "inter_company_order_reference",
"party_account_currency", "party_account_currency",
@@ -1747,10 +1746,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "section_break_zstt",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "title", "fieldname": "title",
@@ -1765,7 +1760,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-05-01 02:37:30.937916", "modified": "2026-05-28 11:41:11.823034",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order", "name": "Sales Order",

View File

@@ -420,42 +420,80 @@ def get_past_order_list(search_term, status, limit=20):
@frappe.whitelist() @frappe.whitelist()
def set_customer_info(fieldname, customer, value=""): def set_customer_info(fieldname, customer, value=""):
customer_doc = frappe.get_doc("Customer", customer)
customer_doc.check_permission("write")
if fieldname == "loyalty_program": if fieldname == "loyalty_program":
frappe.db.set_value("Customer", customer, "loyalty_program", value) customer_doc.loyalty_program = value
else:
contact = customer_doc.get("customer_primary_contact")
if not contact:
Contact = DocType("Contact")
DynamicLink = DocType("Dynamic Link")
contact = frappe.get_cached_value("Customer", customer, "customer_primary_contact") # Inner join with Contact DocType, to priorities records that have is_primary_contact set.
if not contact: query = (
contact = frappe.db.sql( frappe.qb.from_(DynamicLink)
""" .join(Contact)
SELECT parent FROM `tabDynamic Link` .on(DynamicLink.parent == Contact.name)
WHERE .select(DynamicLink.parent)
parenttype = 'Contact' AND .where(
parentfield = 'links' AND (DynamicLink.link_name == customer)
link_doctype = 'Customer' AND & (DynamicLink.parentfield == "links")
link_name = %s & (DynamicLink.parenttype == "Contact")
""", & (DynamicLink.link_doctype == "Customer")
(customer), )
as_dict=1, .orderby(Contact.is_primary_contact, order=Order.desc)
) )
contact = contact[0].get("parent") if contact else None
if not contact: contacts = query.run(pluck=DynamicLink.parent)
new_contact = frappe.new_doc("Contact")
new_contact.is_primary_contact = 1
new_contact.first_name = customer
new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
new_contact.save()
contact = new_contact.name
frappe.db.set_value("Customer", customer, "customer_primary_contact", contact)
contact_doc = frappe.get_doc("Contact", contact) contact = contacts[0] if contacts else None
if fieldname == "email_id":
contact_doc.set("email_ids", [{"email_id": value, "is_primary": 1}]) if not contact:
frappe.db.set_value("Customer", customer, "email_id", value) new_contact = frappe.new_doc("Contact")
elif fieldname == "mobile_no": new_contact.is_primary_contact = 1
contact_doc.set("phone_nos", [{"phone": value, "is_primary_mobile_no": 1}]) new_contact.first_name = customer
frappe.db.set_value("Customer", customer, "mobile_no", value) new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}])
contact_doc.save() new_contact.save()
contact = new_contact.name
def set_primary_phone_no_email(field, value):
# Create new record instead deleting existing email or phone_no and setting the new row as primary.
field_mapper = {
"email_ids": {"field": "email_id", "primary": "is_primary"},
"phone_nos": {"field": "phone", "primary": "is_primary_mobile_no"},
}
value_already_exists = False
for d in contact_doc.get(field):
if d.get(field_mapper[field].get("field")) == value and not value_already_exists:
d.set(field_mapper[field]["primary"], 1)
value_already_exists = True
continue
d.set(field_mapper[field]["primary"], 0)
if not value_already_exists:
contact_doc.append(
field, {field_mapper[field]["field"]: value, field_mapper[field]["primary"]: 1}
)
contact_doc = frappe.get_doc("Contact", contact)
# setting is_primary_contact = 1 on Contact to refetch the same contact incase it's removed from Customer records.
contact_doc.set("is_primary_contact", 1)
if fieldname == "email_id":
set_primary_phone_no_email("email_ids", value)
elif fieldname == "mobile_no":
set_primary_phone_no_email("phone_nos", value)
# Saving contact_doc to set mobile_no and email.
contact_doc.save()
# Auto-fetches from Contact DocType, no need to set values separately.
customer_doc.customer_primary_contact = contact
# using save method instead db.set_value which bypasses the validation for loyalty program
# and auto sets the mobile_no and email field on customer records.
customer_doc.save()
@frappe.whitelist() @frappe.whitelist()

View File

@@ -217,7 +217,7 @@ erpnext.PointOfSale.Controller = class {
set_opening_entry_status() { set_opening_entry_status() {
this.page.set_title_sub( this.page.set_title_sub(
`<span class="indicator orange"> `<span class="indicator orange">
<a class="text-muted" href="#Form/POS%20Opening%20Entry/${this.pos_opening}"> <a class="text-muted" href="#Form/POS%20Opening%20Entry/${encodeURIComponent(this.pos_opening)}">
Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)} Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)}
</a> </a>
</span>` </span>`

View File

@@ -184,7 +184,7 @@ erpnext.PointOfSale.ItemCart = class {
me.$totals_section.find(".edit-cart-btn").click(); me.$totals_section.find(".edit-cart-btn").click();
} }
const item_row_name = unescape($cart_item.attr("data-row-name")); const item_row_name = $cart_item.attr("data-row-name");
me.events.cart_item_clicked({ name: item_row_name }); me.events.cart_item_clicked({ name: item_row_name });
this.numpad_value = ""; this.numpad_value = "";
}); });
@@ -464,10 +464,10 @@ erpnext.PointOfSale.ItemCart = class {
<div class="customer-display"> <div class="customer-display">
${this.get_customer_image()} ${this.get_customer_image()}
<div class="customer-name-desc"> <div class="customer-name-desc">
<div class="customer-name">${customer_name}</div> <div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
${get_customer_description()} ${get_customer_description()}
</div> </div>
<div class="reset-customer-btn" data-customer="${escape(customer)}"> <div class="reset-customer-btn" data-customer="${frappe.utils.escape_html(customer)}">
<svg width="32" height="32" viewBox="0 0 14 14" fill="none"> <svg width="32" height="32" viewBox="0 0 14 14" fill="none">
<path d="M4.93764 4.93759L7.00003 6.99998M9.06243 9.06238L7.00003 6.99998M7.00003 6.99998L4.93764 9.06238L9.06243 4.93759" stroke="#8D99A6"/> <path d="M4.93764 4.93759L7.00003 6.99998M9.06243 9.06238L7.00003 6.99998M7.00003 6.99998L4.93764 9.06238L9.06243 4.93759" stroke="#8D99A6"/>
</svg> </svg>
@@ -484,11 +484,13 @@ erpnext.PointOfSale.ItemCart = class {
if (!email_id && !mobile_no) { if (!email_id && !mobile_no) {
return `<div class="customer-desc">${__("Click to add email / phone")}</div>`; return `<div class="customer-desc">${__("Click to add email / phone")}</div>`;
} else if (email_id && !mobile_no) { } else if (email_id && !mobile_no) {
return `<div class="customer-desc">${email_id}</div>`; return `<div class="customer-desc">${frappe.utils.escape_html(email_id)}</div>`;
} else if (mobile_no && !email_id) { } else if (mobile_no && !email_id) {
return `<div class="customer-desc">${mobile_no}</div>`; return `<div class="customer-desc">${frappe.utils.escape_html(mobile_no)}</div>`;
} else { } else {
return `<div class="customer-desc">${email_id} - ${mobile_no}</div>`; return `<div class="customer-desc">${frappe.utils.escape_html(
email_id
)} - ${frappe.utils.escape_html(mobile_no)}</div>`;
} }
} }
} }
@@ -496,9 +498,13 @@ erpnext.PointOfSale.ItemCart = class {
get_customer_image() { get_customer_image() {
const { customer, image } = this.customer_info || {}; const { customer, image } = this.customer_info || {};
if (image) { if (image) {
return `<div class="customer-image"><img src="${image}" alt="${image}""></div>`; return `<div class="customer-image"><img src="${frappe.utils.escape_html(
image
)}" alt="${frappe.utils.escape_html(image)}"></div>`;
} else { } else {
return `<div class="customer-image customer-abbr">${frappe.get_abbr(customer)}</div>`; return `<div class="customer-image customer-abbr">${frappe.utils.escape_html(
frappe.get_abbr(customer)
)}</div>`;
} }
} }
@@ -559,7 +565,7 @@ erpnext.PointOfSale.ItemCart = class {
.map((t) => { .map((t) => {
if (t.tax_amount_after_discount_amount == 0.0) return; if (t.tax_amount_after_discount_amount == 0.0) return;
return `<div class="tax-row"> return `<div class="tax-row">
<div class="tax-label">${t.description}</div> <div class="tax-label">${frappe.utils.escape_html(t.description)}</div>
<div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, currency)}</div> <div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, currency)}</div>
</div>`; </div>`;
}) })
@@ -571,8 +577,9 @@ erpnext.PointOfSale.ItemCart = class {
} }
get_cart_item({ name }) { get_cart_item({ name }) {
const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`; return this.$cart_items_wrapper.find(".cart-item-wrapper").filter(function () {
return this.$cart_items_wrapper.find(item_selector); return $(this).attr("data-row-name") === name;
});
} }
get_item_from_frm(item) { get_item_from_frm(item) {
@@ -602,7 +609,9 @@ erpnext.PointOfSale.ItemCart = class {
if (!$item_to_update.length) { if (!$item_to_update.length) {
this.$cart_items_wrapper.append( this.$cart_items_wrapper.append(
`<div class="cart-item-wrapper" data-row-name="${escape(item_data.name)}"></div> `<div class="cart-item-wrapper" data-row-name="${frappe.utils.escape_html(
item_data.name
)}"></div>
<div class="seperator"></div>` <div class="seperator"></div>`
); );
$item_to_update = this.get_cart_item(item_data); $item_to_update = this.get_cart_item(item_data);
@@ -612,7 +621,7 @@ erpnext.PointOfSale.ItemCart = class {
`${get_item_image_html()} `${get_item_image_html()}
<div class="item-name-desc"> <div class="item-name-desc">
<div class="item-name"> <div class="item-name">
${item_data.item_name} ${frappe.utils.escape_html(item_data.item_name)}
</div> </div>
${get_description_html()} ${get_description_html()}
</div> </div>
@@ -641,7 +650,7 @@ erpnext.PointOfSale.ItemCart = class {
if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) { if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) {
return ` return `
<div class="item-qty-rate"> <div class="item-qty-rate">
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div> <div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
<div class="item-rate-amount"> <div class="item-rate-amount">
<div class="item-rate">${format_currency(item_data.amount, currency)}</div> <div class="item-rate">${format_currency(item_data.amount, currency)}</div>
<div class="item-amount">${format_currency(item_data.rate, currency)}</div> <div class="item-amount">${format_currency(item_data.rate, currency)}</div>
@@ -650,7 +659,7 @@ erpnext.PointOfSale.ItemCart = class {
} else { } else {
return ` return `
<div class="item-qty-rate"> <div class="item-qty-rate">
<div class="item-qty"><span>${item_data.qty || 0} ${item_data.uom}</span></div> <div class="item-qty"><span>${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</span></div>
<div class="item-rate-amount"> <div class="item-rate-amount">
<div class="item-rate">${format_currency(item_data.rate, currency)}</div> <div class="item-rate">${format_currency(item_data.rate, currency)}</div>
</div> </div>
@@ -671,7 +680,7 @@ erpnext.PointOfSale.ItemCart = class {
} }
} }
item_data.description = frappe.ellipsis(item_data.description, 45); item_data.description = frappe.ellipsis(item_data.description, 45);
return `<div class="item-desc">${item_data.description}</div>`; return `<div class="item-desc">${frappe.utils.escape_html(item_data.description)}</div>`;
} }
return ``; return ``;
} }
@@ -683,22 +692,24 @@ erpnext.PointOfSale.ItemCart = class {
<div class="item-image"> <div class="item-image">
<img <img
onerror="cur_pos.cart.handle_broken_image(this)" onerror="cur_pos.cart.handle_broken_image(this)"
src="${image}" alt="${frappe.get_abbr(item_name)}""> src="${frappe.utils.escape_html(image)}" alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}">
</div>`; </div>`;
} else { } else {
return `<div class="item-image item-abbr">${frappe.get_abbr(item_name)}</div>`; return `<div class="item-image item-abbr">${frappe.utils.escape_html(
frappe.get_abbr(item_name)
)}</div>`;
} }
} }
} }
handle_broken_image($img) { handle_broken_image($img) {
const item_abbr = $($img).attr("alt"); const item_abbr = frappe.utils.escape_html($($img).attr("alt"));
$($img).parent().replaceWith(`<div class="item-image item-abbr">${item_abbr}</div>`); $($img).parent().replaceWith(`<div class="item-image item-abbr">${item_abbr}</div>`);
} }
update_selector_value_in_cart_item(selector, value, item) { update_selector_value_in_cart_item(selector, value, item) {
const $item_to_update = this.get_cart_item(item); const $item_to_update = this.get_cart_item(item);
$item_to_update.attr(`data-${selector}`, escape(value)); $item_to_update.attr(`data-${selector}`, value);
} }
toggle_checkout_btn(show_checkout) { toggle_checkout_btn(show_checkout) {
@@ -899,8 +910,8 @@ erpnext.PointOfSale.ItemCart = class {
<div class="customer-display"> <div class="customer-display">
${this.get_customer_image()} ${this.get_customer_image()}
<div class="customer-name-desc"> <div class="customer-name-desc">
<div class="customer-name">${customer_name}</div> <div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
<div class="customer-desc">${customer}</div> <div class="customer-desc">${frappe.utils.escape_html(customer)}</div>
</div> </div>
</div> </div>
<div class="customer-fields-container"> <div class="customer-fields-container">
@@ -987,6 +998,7 @@ erpnext.PointOfSale.ItemCart = class {
customer: current_customer, customer: current_customer,
value: this.value, value: this.value,
}, },
freeze: true,
callback: (r) => { callback: (r) => {
if (!r.exc) { if (!r.exc) {
me.customer_info[this.df.fieldname] = this.value; me.customer_info[this.df.fieldname] = this.value;
@@ -1040,9 +1052,11 @@ erpnext.PointOfSale.ItemCart = class {
}; };
transaction_container.append( transaction_container.append(
`<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}"> `<div class="invoice-wrapper" data-invoice-name="${frappe.utils.escape_html(
invoice.name
)}">
<div class="invoice-name-date"> <div class="invoice-name-date">
<div class="invoice-name">${invoice.name}</div> <div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
<div class="invoice-date">${posting_datetime}</div> <div class="invoice-date">${posting_datetime}</div>
</div> </div>
<div class="invoice-total-status"> <div class="invoice-total-status">
@@ -1050,7 +1064,7 @@ erpnext.PointOfSale.ItemCart = class {
${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0} ${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0}
</div> </div>
<div class="invoice-status"> <div class="invoice-status">
<span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status]}"> <span class="indicator-pill whitespace-nowrap ${indicator_color[invoice.status] || ""}">
<span>${__(invoice.status)}</span> <span>${__(invoice.status)}</span>
</span> </span>
</div> </div>

View File

@@ -129,24 +129,26 @@ erpnext.PointOfSale.ItemDetails = class {
return ``; return ``;
} }
this.$item_name.html(item_name); this.$item_name.html(frappe.utils.escape_html(item_name));
this.$item_description.html(get_description_html()); this.$item_description.html(get_description_html());
this.$item_price.html(format_currency(price_list_rate, this.currency)); this.$item_price.html(format_currency(price_list_rate, this.currency));
if (!this.hide_images && image) { if (!this.hide_images && image) {
this.$item_image.html( this.$item_image.html(
`<img `<img
onerror="cur_pos.item_details.handle_broken_image(this)" onerror="cur_pos.item_details.handle_broken_image(this)"
class="h-full" src="${image}" class="h-full" src="${frappe.utils.escape_html(image)}"
alt="${frappe.get_abbr(item_name)}" alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}"
style="object-fit: cover;">` style="object-fit: cover;">`
); );
} else { } else {
this.$item_image.html(`<div class="item-abbr">${frappe.get_abbr(item_name)}</div>`); this.$item_image.html(
`<div class="item-abbr">${frappe.utils.escape_html(frappe.get_abbr(item_name))}</div>`
);
} }
} }
handle_broken_image($img) { handle_broken_image($img) {
const item_abbr = $($img).attr("alt"); const item_abbr = frappe.utils.escape_html($($img).attr("alt"));
$($img).replaceWith(`<div class="item-abbr">${item_abbr}</div>`); $($img).replaceWith(`<div class="item-abbr">${item_abbr}</div>`);
} }

View File

@@ -112,17 +112,37 @@ erpnext.PointOfSale.ItemSelector = class {
render_item_list_column_header() { render_item_list_column_header() {
return `<div class="list-column"> return `<div class="list-column">
<div class="column-name">Name</div> <div class="column-name">${__("Name")}</div>
<div class="column-price">Price</div> <div class="column-price">${__("Price")}</div>
<div class="column-uom">UOM</div> <div class="column-uom">${__("UOM")}</div>
<div class="column-qty-available">Quantity Available</div> <div class="column-qty-available">${__("Quantity Available")}</div>
</div>`; </div>`;
} }
get_item_html(item) { get_item_html(item) {
const me = this; const me = this;
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { item_image, serial_no, batch_no, barcode, actual_qty, uom, price_list_rate } = item; function sanitize_item_data(item) {
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [
key,
typeof value === "string" ? frappe.utils.escape_html(value) : value,
])
);
}
const sanitize_item = sanitize_item_data(item);
const {
item_code,
stock_uom,
item_name,
item_image,
serial_no,
batch_no,
barcode,
actual_qty,
uom,
price_list_rate,
} = sanitize_item;
const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0; const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0;
let indicator_color; let indicator_color;
let qty_to_display = actual_qty; let qty_to_display = actual_qty;
@@ -149,37 +169,41 @@ erpnext.PointOfSale.ItemSelector = class {
<img <img
onerror="cur_pos.item_selector.handle_broken_image(this)" onerror="cur_pos.item_selector.handle_broken_image(this)"
class="item-img" src="${item_image}" class="item-img" src="${item_image}"
alt="${item.item_name}" alt="${item_name}"
> >
</div>`; </div>`;
} else { } else {
return `<div class="item-qty-pill"> return `<div class="item-qty-pill">
<span class="indicator-pill whitespace-nowrap ${indicator_color}">${qty_to_display}</span> <span class="indicator-pill whitespace-nowrap ${indicator_color}">${qty_to_display}</span>
</div> </div>
<div class="item-display abbr">${frappe.get_abbr(item.item_name)}</div>`; <div class="item-display abbr">${frappe.get_abbr(item_name)}</div>`;
} }
} }
return `<div class="item-wrapper" return `<div class="item-wrapper"
data-item-code="${escape(item.item_code)}" data-serial-no="${escape(serial_no)}" data-item-code="${item_code}" data-serial-no="${serial_no}"
data-batch-no="${escape(batch_no)}" data-uom="${escape(uom)}" data-batch-no="${batch_no}" data-uom="${uom}"
data-rate="${escape(price_list_rate || 0)}" data-rate="${price_list_rate || 0}"
data-stock-uom="${escape(item.stock_uom)}" data-stock-uom="${stock_uom}"
title="${item.item_name}"> title="${item_name}">
${get_item_image_html()} ${get_item_image_html()}
<div class="item-detail"> <div class="item-detail">
<div class="item-name"> <div class="item-name">
${!me.hide_images ? frappe.ellipsis(item.item_name, 18) : item.item_name} ${!me.hide_images ? frappe.ellipsis(item_name, 18) : item_name}
</div> </div>
${ ${
!me.hide_images !me.hide_images
? `<div class="item-rate"> ? `<div class="item-rate">
${format_currency(price_list_rate, item.currency, precision) || 0} / ${uom} ${frappe.utils.escape_html(format_currency(price_list_rate, item.currency, precision)) || 0} / ${uom}
</div>` </div>`
: ` : `
<div class="item-price">${format_currency(price_list_rate, item.currency, precision) || 0}</div> <div class="item-price">${
frappe.utils.escape_html(
format_currency(price_list_rate, item.currency, precision)
) || 0
}</div>
<div class="item-uom">${uom}</div> <div class="item-uom">${uom}</div>
<div class="item-qty-available">${qty_to_display || "Non stock item"}</div> <div class="item-qty-available">${qty_to_display || "Non stock item"}</div>
` `
@@ -189,7 +213,7 @@ erpnext.PointOfSale.ItemSelector = class {
} }
handle_broken_image($img) { handle_broken_image($img) {
const item_abbr = $($img).attr("alt"); const item_abbr = frappe.utils.escape_html($($img).attr("alt"));
$($img).parent().replaceWith(`<div class="item-display abbr">${item_abbr}</div>`); $($img).parent().replaceWith(`<div class="item-display abbr">${item_abbr}</div>`);
} }
@@ -244,7 +268,7 @@ erpnext.PointOfSale.ItemSelector = class {
set_item_selector_filter_label(value) { set_item_selector_filter_label(value) {
const $filter_label = this.$component.find(".label"); const $filter_label = this.$component.find(".label");
$filter_label.html(value ? __(value) : __("All Items")); $filter_label.html(value ? frappe.utils.escape_html(__(value)) : __("All Items"));
} }
hide_open_link_btn() { hide_open_link_btn() {
@@ -329,12 +353,12 @@ erpnext.PointOfSale.ItemSelector = class {
this.$component.on("click", ".item-wrapper", function () { this.$component.on("click", ".item-wrapper", function () {
const $item = $(this); const $item = $(this);
const item_code = unescape($item.attr("data-item-code")); const item_code = $item.attr("data-item-code");
let batch_no = unescape($item.attr("data-batch-no")); let batch_no = $item.attr("data-batch-no");
let serial_no = unescape($item.attr("data-serial-no")); let serial_no = $item.attr("data-serial-no");
let uom = unescape($item.attr("data-uom")); let uom = $item.attr("data-uom");
let rate = unescape($item.attr("data-rate")); let rate = $item.attr("data-rate");
let stock_uom = unescape($item.attr("data-stock-uom")); let stock_uom = $item.attr("data-stock-uom");
// escape(undefined) returns "undefined" then unescape returns "undefined" // escape(undefined) returns "undefined" then unescape returns "undefined"
batch_no = batch_no === "undefined" ? undefined : batch_no; batch_no = batch_no === "undefined" ? undefined : batch_no;

View File

@@ -42,7 +42,7 @@ erpnext.PointOfSale.PastOrderList = class {
this.$invoices_container.on("click", ".invoice-wrapper", function () { this.$invoices_container.on("click", ".invoice-wrapper", function () {
const invoice_clicked = $(this); const invoice_clicked = $(this);
const invoice_doctype = invoice_clicked.attr("data-invoice-doctype"); const invoice_doctype = invoice_clicked.attr("data-invoice-doctype");
const invoice_name = unescape(invoice_clicked.attr("data-invoice-name")); const invoice_name = invoice_clicked.attr("data-invoice-name");
$(".invoice-wrapper").removeClass("invoice-selected"); $(".invoice-wrapper").removeClass("invoice-selected");
invoice_clicked.addClass("invoice-selected"); invoice_clicked.addClass("invoice-selected");
@@ -108,15 +108,15 @@ erpnext.PointOfSale.PastOrderList = class {
); );
return `<div class="invoice-wrapper" data-invoice-doctype="${ return `<div class="invoice-wrapper" data-invoice-doctype="${
invoice.doctype invoice.doctype
}" data-invoice-name="${escape(invoice.name)}"> }" data-invoice-name="${frappe.utils.escape_html(invoice.name)}">
<div class="invoice-name-customer"> <div class="invoice-name-customer">
<div class="invoice-customer"> <div class="invoice-customer">
<svg class="mr-2" width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"> <svg class="mr-2" width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg> </svg>
${frappe.ellipsis(invoice.customer_name, 20)} ${frappe.utils.escape_html(frappe.ellipsis(invoice.customer_name, 20))}
</div> </div>
<div class="invoice-name">${invoice.name}</div> <div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
</div> </div>
<div class="invoice-total-date"> <div class="invoice-total-date">
<div class="invoice-total">${format_currency(invoice.grand_total, invoice.currency) || 0}</div> <div class="invoice-total">${format_currency(invoice.grand_total, invoice.currency) || 0}</div>

View File

@@ -82,15 +82,19 @@ erpnext.PointOfSale.PastOrderSummary = class {
return `<div class="left-section"> return `<div class="left-section">
<div class="customer-section"> <div class="customer-section">
<div class="customer-name">${doc.customer_name}</div> <div class="customer-name">${frappe.utils.escape_html(doc.customer_name)}</div>
${is_customer_naming_by_customer_name ? `<div class="customer-code">${doc.customer}</div>` : ""} ${
<div class="customer-email">${this.customer_email}</div> is_customer_naming_by_customer_name
? `<div class="customer-code">${frappe.utils.escape_html(doc.customer)}</div>`
: ""
}
<div class="customer-email">${frappe.utils.escape_html(this.customer_email)}</div>
</div> </div>
<div class="cashier">${__("Sold by")}: ${doc.owner}</div> <div class="cashier">${__("Sold by")}: ${frappe.utils.escape_html(doc.owner)}</div>
</div> </div>
<div class="right-section"> <div class="right-section">
<div class="paid-amount">${format_currency(doc.paid_amount, doc.currency)}</div> <div class="paid-amount">${format_currency(doc.paid_amount, doc.currency)}</div>
<div class="invoice-name">${doc.name}</div> <div class="invoice-name">${frappe.utils.escape_html(doc.name)}</div>
<span class="indicator-pill whitespace-nowrap ${indicator_color}"><span>${__(doc.status)}</span></span> <span class="indicator-pill whitespace-nowrap ${indicator_color}"><span>${__(doc.status)}</span></span>
</div>`; </div>`;
} }
@@ -100,8 +104,8 @@ erpnext.PointOfSale.PastOrderSummary = class {
return `<div class="item-row-wrapper"> return `<div class="item-row-wrapper">
<div class="item-row-data"> <div class="item-row-data">
<div class="item-name">${item_data.item_name}</div> <div class="item-name">${frappe.utils.escape_html(item_data.item_name)}</div>
<div class="item-qty">${item_data.qty || 0} ${item_data.uom}</div> <div class="item-qty">${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}</div>
<div class="item-rate-disc">${get_rate_discount_html()}</div> <div class="item-rate-disc">${get_rate_discount_html()}</div>
</div> </div>
@@ -166,7 +170,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
.map((t) => { .map((t) => {
return ` return `
<div class="tax-row"> <div class="tax-row">
<div class="tax-label">${t.description}</div> <div class="tax-label">${frappe.utils.escape_html(t.description)}</div>
<div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, doc.currency)}</div> <div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, doc.currency)}</div>
</div> </div>
`; `;
@@ -185,7 +189,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
get_payment_html(doc, payment) { get_payment_html(doc, payment) {
return `<div class="summary-row-wrapper payments"> return `<div class="summary-row-wrapper payments">
<div>${__(payment.mode_of_payment)}</div> <div>${frappe.utils.escape_html(__(payment.mode_of_payment))}</div>
<div>${format_currency(payment.amount, doc.currency)}</div> <div>${format_currency(payment.amount, doc.currency)}</div>
</div>`; </div>`;
} }

View File

@@ -519,7 +519,7 @@ erpnext.PointOfSale.Payment = class {
return ` return `
<div class="payment-mode-wrapper"> <div class="payment-mode-wrapper">
<div class="mode-of-payment" data-mode="${mode}" data-payment-type="${payment_type}"> <div class="mode-of-payment" data-mode="${mode}" data-payment-type="${payment_type}">
${p.mode_of_payment} ${frappe.utils.escape_html(p.mode_of_payment)}
<div class="${mode}-amount pay-amount">${amount}</div> <div class="${mode}-amount pay-amount">${amount}</div>
<div class="${mode} mode-of-payment-control"></div> <div class="${mode} mode-of-payment-control"></div>
</div> </div>
@@ -603,7 +603,7 @@ erpnext.PointOfSale.Payment = class {
<div class="mode-of-payment loyalty-card" data-mode="loyalty-amount" data-payment-type="loyalty-amount"> <div class="mode-of-payment loyalty-card" data-mode="loyalty-amount" data-payment-type="loyalty-amount">
Redeem Loyalty Points Redeem Loyalty Points
<div class="loyalty-amount-amount pay-amount">${amount}</div> <div class="loyalty-amount-amount pay-amount">${amount}</div>
<div class="loyalty-amount-name">${loyalty_program}</div> <div class="loyalty-amount-name">${frappe.utils.escape_html(loyalty_program)}</div>
<div class="loyalty-amount mode-of-payment-control"></div> <div class="loyalty-amount mode-of-payment-control"></div>
</div> </div>
</div>` </div>`

View File

@@ -102,6 +102,7 @@ def get_opp_by(by_field, from_date, to_date, company):
}, },
) )
for x in opportunities for x in opportunities
if x.get(by_field)
] ]
summary = {} summary = {}

View File

@@ -191,12 +191,30 @@ class Analytics:
self.get_sales_transactions_based_on_project() self.get_sales_transactions_based_on_project()
self.get_rows() self.get_rows()
def _get_permitted_parent_names(self):
return frappe.qb.get_query(
table=self.filters.doc_type,
fields=["name"],
filters={
"docstatus": 1,
"company": ["in", self.filters.company],
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
},
ignore_permissions=False,
).run(pluck="name")
def get_sales_transactions_based_on_order_type(self): def get_sales_transactions_based_on_order_type(self):
if self.filters["value_quantity"] == "Value": if self.filters["value_quantity"] == "Value":
value_field = "base_net_total" value_field = "base_net_total"
else: else:
value_field = "total_qty" value_field = "total_qty"
permitted_names = self._get_permitted_parent_names()
if not permitted_names:
self.entries = []
self.get_teams()
return
doctype = DocType(self.filters.doc_type) doctype = DocType(self.filters.doc_type)
self.entries = ( self.entries = (
@@ -206,12 +224,7 @@ class Analytics:
doctype[self.date_field], doctype[self.date_field],
doctype[value_field].as_("value_field"), doctype[value_field].as_("value_field"),
) )
.where( .where((doctype.name.isin(permitted_names)) & (IfNull(doctype.order_type, "") != ""))
(doctype.docstatus == 1)
& (doctype.company.isin(self.filters.company))
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
& (IfNull(doctype.order_type, "") != "")
)
.orderby(doctype.order_type) .orderby(doctype.order_type)
).run(as_dict=True) ).run(as_dict=True)
@@ -250,9 +263,12 @@ class Analytics:
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
filters.update({"is_opening": "No"}) filters.update({"is_opening": "No"})
self.entries = frappe.get_all( self.entries = frappe.qb.get_query(
self.filters.doc_type, fields=[entity, entity_name, value_field, self.date_field], filters=filters table=self.filters.doc_type,
) fields=[entity, entity_name, value_field, self.date_field],
filters=filters,
ignore_permissions=False,
).run(as_dict=True)
self.entity_names = {} self.entity_names = {}
for d in self.entries: for d in self.entries:
@@ -264,6 +280,12 @@ class Analytics:
else: else:
value_field = "stock_qty" value_field = "stock_qty"
permitted_names = self._get_permitted_parent_names()
if not permitted_names:
self.entries = []
self.entity_names = {}
return
doctype = DocType(self.filters.doc_type) doctype = DocType(self.filters.doc_type)
doctype_item = DocType(f"{self.filters.doc_type} Item") doctype_item = DocType(f"{self.filters.doc_type} Item")
@@ -278,11 +300,7 @@ class Analytics:
doctype_item[value_field].as_("value_field"), doctype_item[value_field].as_("value_field"),
doctype[self.date_field], doctype[self.date_field],
) )
.where( .where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
(doctype_item.docstatus == 1)
& (doctype.company.isin(self.filters.company))
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
)
).run(as_dict=True) ).run(as_dict=True)
self.entity_names = {} self.entity_names = {}
@@ -312,11 +330,12 @@ class Analytics:
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
filters.update({"is_opening": "No"}) filters.update({"is_opening": "No"})
self.entries = frappe.get_all( self.entries = frappe.qb.get_query(
self.filters.doc_type, table=self.filters.doc_type,
fields=[entity_field, value_field, self.date_field], fields=[entity_field, value_field, self.date_field],
filters=filters, filters=filters,
) ignore_permissions=False,
).run(as_dict=True)
self.get_groups() self.get_groups()
def get_sales_transactions_based_on_item_group(self): def get_sales_transactions_based_on_item_group(self):
@@ -325,6 +344,12 @@ class Analytics:
else: else:
value_field = "qty" value_field = "qty"
permitted_names = self._get_permitted_parent_names()
if not permitted_names:
self.entries = []
self.get_groups()
return
doctype = DocType(self.filters.doc_type) doctype = DocType(self.filters.doc_type)
doctype_item = DocType(f"{self.filters.doc_type} Item") doctype_item = DocType(f"{self.filters.doc_type} Item")
@@ -337,11 +362,7 @@ class Analytics:
doctype_item[value_field].as_("value_field"), doctype_item[value_field].as_("value_field"),
doctype[self.date_field], doctype[self.date_field],
) )
.where( .where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names)))
(doctype_item.docstatus == 1)
& (doctype.company.isin(self.filters.company))
& (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date))
)
).run(as_dict=True) ).run(as_dict=True)
self.get_groups() self.get_groups()
@@ -367,9 +388,12 @@ class Analytics:
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
filters.update({"is_opening": "No"}) filters.update({"is_opening": "No"})
self.entries = frappe.get_all( self.entries = frappe.qb.get_query(
self.filters.doc_type, fields=[entity, value_field, self.date_field], filters=filters table=self.filters.doc_type,
) fields=[entity, value_field, self.date_field],
filters=filters,
ignore_permissions=False,
).run(as_dict=True)
def get_rows(self): def get_rows(self):
self.data = [] self.data = []

View File

@@ -4219,9 +4219,14 @@
}, },
"Japan": { "Japan": {
"Japan Tax": { "Japan Tax 10%": {
"account_name": "CT", "account_name": "CT 10%",
"tax_rate": 5.00 "tax_rate": 10.00,
"default": 1
},
"Japan Tax 8%": {
"account_name": "CT 8%",
"tax_rate": 8.00
} }
}, },

View File

@@ -264,8 +264,9 @@ def update_qty(bin_name, args):
# actual qty is already updated by processing current voucher # actual qty is already updated by processing current voucher
actual_qty = bin_details.actual_qty or 0.0 actual_qty = bin_details.actual_qty or 0.0
# actual qty is not up to date in case of backdated transaction # actual qty is not up to date in case of backdated transactions
if future_sle_exists(args): # or when cancellations are the most recent SLE
if future_sle_exists(args) or args.get("is_cancelled"):
actual_qty = get_actual_qty(args.get("item_code"), args.get("warehouse")) actual_qty = get_actual_qty(args.get("item_code"), args.get("warehouse"))
ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty")) ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))

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