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):
_ensure_idle_system()
account = frappe.get_cached_doc("Account", name)
account.check_permission("write")
if not account:
return

View File

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

View File

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

View File

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

View File

@@ -565,18 +565,19 @@ class FinancialQueryBuilder:
frappe.qb.from_(acb_table)
.select(
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.account.isin(account_names))
.where(acb_table.period_closing_voucher == closing_voucher)
.groupby(acb_table.account)
)
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
results = self._execute_with_permissions(query, "Account Closing Balance")
for row in results:
closing_balances[row["account"]] = row["balance"]
closing_balances[row["account"]] = row["balance"] or 0.0
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.utils import get_currency_precision, get_fiscal_year
from erpnext.tests.utils import change_settings
class TestDependencyResolver(FinancialReportTemplateTestCase):
@@ -1953,6 +1954,104 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
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):
"""
Sequence:

View File

@@ -9,6 +9,14 @@ from erpnext.tests.utils import ERPNextTestSuite
class FinancialReportTemplateTestCase(ERPNextTestSuite):
"""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):
"""Set up test data"""
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) {
var row = frappe.get_doc(cdt, cdn);
row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
if (!row.exchange_rate) row.exchange_rate = 1;
if (!row.account) {
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
}
// set difference
if (doc.difference) {

View File

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

View File

@@ -3568,3 +3568,16 @@ def make_payment_order(source_name, target_doc=None):
@erpnext.allow_regional
def add_regional_gl_entries(gl_entries, doc):
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",
"amended_from",
"return_against",
"section_break_abck",
"title",
"accounting_dimensions_section",
"project",
"dimension_col_break",
@@ -172,6 +170,7 @@
"is_discounted",
"col_break23",
"status",
"title",
"more_info",
"debit_to",
"party_account_currency",
@@ -1625,10 +1624,6 @@
"fieldtype": "Section Break",
"label": "Auto Repeat"
},
{
"fieldname": "section_break_abck",
"fieldtype": "Section Break"
},
{
"allow_on_submit": 1,
"fieldname": "title",
@@ -1641,7 +1636,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-05-01 02:37:30.580568",
"modified": "2026-05-28 12:22:50.253090",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
bank_cash_account: DF.Link | None
company: DF.Link
cost_center: DF.Link | None
default_advance_account: DF.Link
default_advance_account: DF.Link | None
error_log: DF.LongText | None
from_invoice_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"]
def get_filters_as_tuple(fields, doc):
filters = ()
for x in fields:
filters += tuple(doc.get(x))
return filters
return tuple(doc.get(x) or "" for x in fields)
for x in all_queued:
doc = frappe.get_doc("Process Payment Reconciliation", x)

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
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.controllers.accounts_controller import validate_account_head
@@ -48,7 +48,7 @@ class TaxWithholdingCategory(Document):
for d in self.get("rates"):
if getdate(d.from_date) >= getdate(d.to_date):
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
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):
for row in self.rates:
if (
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date)
and row.tax_withholding_group == tax_withholding_group
):
if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
row.tax_withholding_group
) == cstr(tax_withholding_group):
return row
frappe.throw(_("No Tax Withholding data found for the current posting date."))
@@ -116,7 +115,7 @@ class TaxWithholdingDetails:
def __init__(
self,
tax_withholding_categories: list[str],
tax_withholding_group: str,
tax_withholding_group: str | None,
posting_date: str,
party_type: str,
party: str,

View File

@@ -999,6 +999,47 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
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):
self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS")
invoices = []

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Purchase Invoice",
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
"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\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Bills",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"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\",\"payment_type\",\"=\",\"Receive\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Payment",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Payment",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Invoice",
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
"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\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Bills",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"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\",\"payment_type\",\"=\",\"Pay\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Payment",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Payment",

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.query_builder import Order
from frappe.query_builder.functions import Max, Min
from frappe.utils import (
DateTimeLikeObject,
add_months,
cint,
flt,
@@ -359,7 +360,8 @@ def get_message_for_depr_entry_posting_error(asset_links, error_log_links):
@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)
scrap_date = getdate(scrap_date) or getdate(today())
asset.db_set("disposal_date", scrap_date)
@@ -448,7 +450,8 @@ def create_journal_entry_for_scrap(asset, scrap_date):
@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)
reverse_depreciation_entry_made_on_disposal(asset)
reset_depreciation_schedule(asset, get_note_for_restore(asset))

View File

@@ -17,6 +17,7 @@
"section_break_vwgg",
"maintain_same_rate",
"column_break_lwxs",
"set_landed_cost_based_on_purchase_invoice_rate",
"maintain_same_rate_action",
"role_to_override_stop_action",
"transaction_settings_section",
@@ -24,7 +25,8 @@
"po_required",
"pr_required",
"project_update_frequency",
"column_break_12",
"over_order_allowance",
"column_break_kdcm",
"allow_multiple_items",
"allow_negative_rates_for_items",
"set_valuation_rate_for_rejected_materials",
@@ -33,7 +35,6 @@
"purchase_invoice_settings_section",
"bill_for_rejected_quantity_in_purchase_invoice",
"use_transaction_date_exchange_rate",
"set_landed_cost_based_on_purchase_invoice_rate",
"zero_quantity_line_items_section",
"allow_zero_qty_in_supplier_quotation",
"allow_zero_qty_in_request_for_quotation",
@@ -156,10 +157,6 @@
"fieldtype": "Tab Break",
"label": "Transaction Settings"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"default": "0",
"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,
"is_virtual": 1,
"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,
@@ -343,7 +350,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-05-05 16:30:37.184607",
"modified": "2026-05-27 23:04:00.842393",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",

View File

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

View File

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

View File

@@ -182,6 +182,9 @@ class PurchaseOrder(BuyingController):
"target_ref_field": "stock_qty",
"source_field": "stock_qty",
"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_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):
"""Test impact on linked PO and MR on deleting/updating row."""
mr = make_material_request(qty=10)

View File

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

View File

@@ -6,6 +6,7 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments
from frappe.model.mapper import get_mapped_doc
@@ -275,12 +276,20 @@ class RequestforQuotation(BuyingController):
supplier_doc.save()
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(
{
"doctype": "User",
"send_welcome_email": 0,
"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",
"redirect_url": link,
}

View File

@@ -20,8 +20,6 @@
"quotation_number",
"has_unit_price_items",
"amended_from",
"section_break_kumc",
"title",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -118,6 +116,7 @@
"more_info",
"is_subcontracted",
"column_break_57",
"title",
"opportunity",
"connections_tab"
],
@@ -940,10 +939,6 @@
"fieldname": "auto_repeat_section",
"fieldtype": "Section Break",
"label": "Auto Repeat"
},
{
"fieldname": "section_break_kumc",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
@@ -952,7 +947,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-04-28 06:23:52.813948",
"modified": "2026-05-28 12:29:37.509487",
"modified_by": "Administrator",
"module": "Buying",
"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))
)
query = frappe.qb.get_query(
"Delivery Note",
fields=fields,
filters=filters,
ignore_permissions=False,
)
query = (
frappe.qb.from_(DeliveryNote)
.select(*[DeliveryNote[f] for f in fields])
.where(
query.where(
(DeliveryNote.docstatus == 1)
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
& (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)

View File

@@ -262,15 +262,17 @@ class StatusUpdater(Document):
def validate_qty(self):
"""Validates qty at row level"""
self.item_allowance = {}
self.global_qty_allowance = None
self.global_amount_allowance = None
for args in self.status_updater:
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
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 = []
selling_negative_rate_allowed = frappe.get_single_value(
"Selling Settings", "allow_negative_rates_for_items"
@@ -402,9 +404,12 @@ class StatusUpdater(Document):
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"
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
(
@@ -419,6 +424,9 @@ class StatusUpdater(Document):
self.global_qty_allowance,
self.global_amount_allowance,
qty_or_amount,
global_qty_allowance_field,
global_qty_allowance_doctype,
item_qty_allowance_field,
)
if args["source_dt"] != "Pick List Item"
else (0, {}, None, None)
@@ -463,7 +471,9 @@ class StatusUpdater(Document):
"Quotation 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 = _(
'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)
@frappe.request_cache
def get_allowance_for(
item_code,
item_allowance=None,
global_qty_allowance=None,
global_amount_allowance=None,
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:
item_allowance = {}
@@ -755,13 +777,13 @@ def get_allowance_for(
)
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 global_qty_allowance is None:
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
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()
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):
items = json.loads(items)
@@ -2124,13 +2124,30 @@ def check_item_quality_inspection(doctype, items):
"Delivery Note": "inspection_required_before_delivery",
}
items_to_remove = []
for item in items:
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)):
items_to_remove.append(item)
items = [item for item in items if item not in items_to_remove]
inspection_fieldname = inspection_fieldname_map.get(doctype)
if inspection_fieldname is None:
return []
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()

View File

@@ -7,6 +7,7 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, get_link_to_form
@@ -1568,7 +1569,13 @@ def make_return_stock_entry_for_subcontract(
@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):
rm_details = json.loads(rm_details)

View File

@@ -347,7 +347,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db()
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()
self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
@@ -404,7 +409,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db()
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].s_warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC")
@@ -1133,7 +1143,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db()
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].s_warehouse, "_Test Warehouse 1 - _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) {
if (frm.doc.routing) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

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

View File

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

View File

@@ -176,7 +176,7 @@ class JobCard(Document):
self.validate_semi_finished_goods()
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
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",
source_name,
{
"Job Card": {
"doctype": "Purchase Order",
},
"Job Card": {"doctype": "Purchase Order", "field_no_map": ["naming_series"]},
},
target_doc,
set_missing_values,

View File

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

View File

@@ -202,7 +202,7 @@ class WorkOrder(Document):
self.calculate_operating_cost()
self.validate_qty()
self.validate_transfer_against()
self.validate_operation_time()
self.validate_operations()
self.status = self.get_status()
self.validate_workstation_type()
self.reset_use_multi_level_bom()
@@ -1499,8 +1499,11 @@ class WorkOrder(Document):
title=_("Missing value"),
)
def validate_operation_time(self):
def validate_operations(self):
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:
frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))

View File

@@ -196,10 +196,11 @@
"read_only": 1
},
{
"default": "1",
"fieldname": "batch_size",
"fieldtype": "Float",
"label": "Batch Size",
"read_only": 1
"non_negative": 1
},
{
"fieldname": "sequence_id",
@@ -316,7 +317,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-20 13:01:21.827200",
"modified": "2026-05-25 17:15:12.038470",
"modified_by": "Administrator",
"module": "Manufacturing",
"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",
args: {
doctype: this.frm.doc.doctype,
docstatus: this.frm.doc.docstatus,
items: this.frm.doc.items,
},
freeze: true,
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) => {
if (me.has_inspection_required(item)) {
let dialog_items = dialog.fields_dict.items;

View File

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

View File

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

View File

@@ -420,42 +420,80 @@ def get_past_order_list(search_term, status, limit=20):
@frappe.whitelist()
def set_customer_info(fieldname, customer, value=""):
customer_doc = frappe.get_doc("Customer", customer)
customer_doc.check_permission("write")
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")
if not contact:
contact = frappe.db.sql(
"""
SELECT parent FROM `tabDynamic Link`
WHERE
parenttype = 'Contact' AND
parentfield = 'links' AND
link_doctype = 'Customer' AND
link_name = %s
""",
(customer),
as_dict=1,
)
contact = contact[0].get("parent") if contact else None
# Inner join with Contact DocType, to priorities records that have is_primary_contact set.
query = (
frappe.qb.from_(DynamicLink)
.join(Contact)
.on(DynamicLink.parent == Contact.name)
.select(DynamicLink.parent)
.where(
(DynamicLink.link_name == customer)
& (DynamicLink.parentfield == "links")
& (DynamicLink.parenttype == "Contact")
& (DynamicLink.link_doctype == "Customer")
)
.orderby(Contact.is_primary_contact, order=Order.desc)
)
if not contact:
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)
contacts = query.run(pluck=DynamicLink.parent)
contact_doc = frappe.get_doc("Contact", contact)
if fieldname == "email_id":
contact_doc.set("email_ids", [{"email_id": value, "is_primary": 1}])
frappe.db.set_value("Customer", customer, "email_id", value)
elif fieldname == "mobile_no":
contact_doc.set("phone_nos", [{"phone": value, "is_primary_mobile_no": 1}])
frappe.db.set_value("Customer", customer, "mobile_no", value)
contact_doc.save()
contact = contacts[0] if contacts else None
if not contact:
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
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()

View File

@@ -217,7 +217,7 @@ erpnext.PointOfSale.Controller = class {
set_opening_entry_status() {
this.page.set_title_sub(
`<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)}
</a>
</span>`

View File

@@ -184,7 +184,7 @@ erpnext.PointOfSale.ItemCart = class {
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 });
this.numpad_value = "";
});
@@ -464,10 +464,10 @@ erpnext.PointOfSale.ItemCart = class {
<div class="customer-display">
${this.get_customer_image()}
<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()}
</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">
<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>
@@ -484,11 +484,13 @@ erpnext.PointOfSale.ItemCart = class {
if (!email_id && !mobile_no) {
return `<div class="customer-desc">${__("Click to add email / phone")}</div>`;
} 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) {
return `<div class="customer-desc">${mobile_no}</div>`;
return `<div class="customer-desc">${frappe.utils.escape_html(mobile_no)}</div>`;
} 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() {
const { customer, image } = this.customer_info || {};
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 {
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) => {
if (t.tax_amount_after_discount_amount == 0.0) return;
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>`;
})
@@ -571,8 +577,9 @@ erpnext.PointOfSale.ItemCart = class {
}
get_cart_item({ name }) {
const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`;
return this.$cart_items_wrapper.find(item_selector);
return this.$cart_items_wrapper.find(".cart-item-wrapper").filter(function () {
return $(this).attr("data-row-name") === name;
});
}
get_item_from_frm(item) {
@@ -602,7 +609,9 @@ erpnext.PointOfSale.ItemCart = class {
if (!$item_to_update.length) {
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>`
);
$item_to_update = this.get_cart_item(item_data);
@@ -612,7 +621,7 @@ erpnext.PointOfSale.ItemCart = class {
`${get_item_image_html()}
<div class="item-name-desc">
<div class="item-name">
${item_data.item_name}
${frappe.utils.escape_html(item_data.item_name)}
</div>
${get_description_html()}
</div>
@@ -641,7 +650,7 @@ erpnext.PointOfSale.ItemCart = class {
if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) {
return `
<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">${format_currency(item_data.amount, currency)}</div>
<div class="item-amount">${format_currency(item_data.rate, currency)}</div>
@@ -650,7 +659,7 @@ erpnext.PointOfSale.ItemCart = class {
} else {
return `
<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">${format_currency(item_data.rate, currency)}</div>
</div>
@@ -671,7 +680,7 @@ erpnext.PointOfSale.ItemCart = class {
}
}
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 ``;
}
@@ -683,22 +692,24 @@ erpnext.PointOfSale.ItemCart = class {
<div class="item-image">
<img
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>`;
} 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) {
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>`);
}
update_selector_value_in_cart_item(selector, value, 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) {
@@ -899,8 +910,8 @@ erpnext.PointOfSale.ItemCart = class {
<div class="customer-display">
${this.get_customer_image()}
<div class="customer-name-desc">
<div class="customer-name">${customer_name}</div>
<div class="customer-desc">${customer}</div>
<div class="customer-name">${frappe.utils.escape_html(customer_name)}</div>
<div class="customer-desc">${frappe.utils.escape_html(customer)}</div>
</div>
</div>
<div class="customer-fields-container">
@@ -987,6 +998,7 @@ erpnext.PointOfSale.ItemCart = class {
customer: current_customer,
value: this.value,
},
freeze: true,
callback: (r) => {
if (!r.exc) {
me.customer_info[this.df.fieldname] = this.value;
@@ -1040,9 +1052,11 @@ erpnext.PointOfSale.ItemCart = class {
};
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">${invoice.name}</div>
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
<div class="invoice-date">${posting_datetime}</div>
</div>
<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}
</div>
<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>
</div>

View File

@@ -129,24 +129,26 @@ erpnext.PointOfSale.ItemDetails = class {
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_price.html(format_currency(price_list_rate, this.currency));
if (!this.hide_images && image) {
this.$item_image.html(
`<img
onerror="cur_pos.item_details.handle_broken_image(this)"
class="h-full" src="${image}"
alt="${frappe.get_abbr(item_name)}"
class="h-full" src="${frappe.utils.escape_html(image)}"
alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}"
style="object-fit: cover;">`
);
} 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) {
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>`);
}

View File

@@ -112,17 +112,37 @@ erpnext.PointOfSale.ItemSelector = class {
render_item_list_column_header() {
return `<div class="list-column">
<div class="column-name">Name</div>
<div class="column-price">Price</div>
<div class="column-uom">UOM</div>
<div class="column-qty-available">Quantity Available</div>
<div class="column-name">${__("Name")}</div>
<div class="column-price">${__("Price")}</div>
<div class="column-uom">${__("UOM")}</div>
<div class="column-qty-available">${__("Quantity Available")}</div>
</div>`;
}
get_item_html(item) {
const me = this;
// 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;
let indicator_color;
let qty_to_display = actual_qty;
@@ -149,37 +169,41 @@ erpnext.PointOfSale.ItemSelector = class {
<img
onerror="cur_pos.item_selector.handle_broken_image(this)"
class="item-img" src="${item_image}"
alt="${item.item_name}"
alt="${item_name}"
>
</div>`;
} else {
return `<div class="item-qty-pill">
<span class="indicator-pill whitespace-nowrap ${indicator_color}">${qty_to_display}</span>
</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"
data-item-code="${escape(item.item_code)}" data-serial-no="${escape(serial_no)}"
data-batch-no="${escape(batch_no)}" data-uom="${escape(uom)}"
data-rate="${escape(price_list_rate || 0)}"
data-stock-uom="${escape(item.stock_uom)}"
title="${item.item_name}">
data-item-code="${item_code}" data-serial-no="${serial_no}"
data-batch-no="${batch_no}" data-uom="${uom}"
data-rate="${price_list_rate || 0}"
data-stock-uom="${stock_uom}"
title="${item_name}">
${get_item_image_html()}
<div class="item-detail">
<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>
${
!me.hide_images
? `<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 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-qty-available">${qty_to_display || "Non stock item"}</div>
`
@@ -189,7 +213,7 @@ erpnext.PointOfSale.ItemSelector = class {
}
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>`);
}
@@ -244,7 +268,7 @@ erpnext.PointOfSale.ItemSelector = class {
set_item_selector_filter_label(value) {
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() {
@@ -329,12 +353,12 @@ erpnext.PointOfSale.ItemSelector = class {
this.$component.on("click", ".item-wrapper", function () {
const $item = $(this);
const item_code = unescape($item.attr("data-item-code"));
let batch_no = unescape($item.attr("data-batch-no"));
let serial_no = unescape($item.attr("data-serial-no"));
let uom = unescape($item.attr("data-uom"));
let rate = unescape($item.attr("data-rate"));
let stock_uom = unescape($item.attr("data-stock-uom"));
const item_code = $item.attr("data-item-code");
let batch_no = $item.attr("data-batch-no");
let serial_no = $item.attr("data-serial-no");
let uom = $item.attr("data-uom");
let rate = $item.attr("data-rate");
let stock_uom = $item.attr("data-stock-uom");
// escape(undefined) returns "undefined" then unescape returns "undefined"
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 () {
const invoice_clicked = $(this);
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_clicked.addClass("invoice-selected");
@@ -108,15 +108,15 @@ erpnext.PointOfSale.PastOrderList = class {
);
return `<div class="invoice-wrapper" data-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-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">
<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>
${frappe.ellipsis(invoice.customer_name, 20)}
${frappe.utils.escape_html(frappe.ellipsis(invoice.customer_name, 20))}
</div>
<div class="invoice-name">${invoice.name}</div>
<div class="invoice-name">${frappe.utils.escape_html(invoice.name)}</div>
</div>
<div class="invoice-total-date">
<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">
<div class="customer-section">
<div class="customer-name">${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>
<div class="customer-name">${frappe.utils.escape_html(doc.customer_name)}</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 class="cashier">${__("Sold by")}: ${doc.owner}</div>
<div class="cashier">${__("Sold by")}: ${frappe.utils.escape_html(doc.owner)}</div>
</div>
<div class="right-section">
<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>
</div>`;
}
@@ -100,8 +104,8 @@ erpnext.PointOfSale.PastOrderSummary = class {
return `<div class="item-row-wrapper">
<div class="item-row-data">
<div class="item-name">${item_data.item_name}</div>
<div class="item-qty">${item_data.qty || 0} ${item_data.uom}</div>
<div class="item-name">${frappe.utils.escape_html(item_data.item_name)}</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>
@@ -166,7 +170,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
.map((t) => {
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, doc.currency)}</div>
</div>
`;
@@ -185,7 +189,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
get_payment_html(doc, payment) {
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>`;
}

View File

@@ -519,7 +519,7 @@ erpnext.PointOfSale.Payment = class {
return `
<div class="payment-mode-wrapper">
<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} mode-of-payment-control"></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">
Redeem Loyalty Points
<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>
</div>`

View File

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

View File

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

View File

@@ -4219,9 +4219,14 @@
},
"Japan": {
"Japan Tax": {
"account_name": "CT",
"tax_rate": 5.00
"Japan Tax 10%": {
"account_name": "CT 10%",
"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 = bin_details.actual_qty or 0.0
# actual qty is not up to date in case of backdated transaction
if future_sle_exists(args):
# actual qty is not up to date in case of backdated transactions
# 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"))
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