mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 06:31:48 +00:00
Merge remote-tracking branch 'origin/develop' into item-level-close
# Conflicts: # erpnext/buying/doctype/purchase_order_item/purchase_order_item.json # erpnext/selling/doctype/sales_order_item/sales_order_item.json # erpnext/stock/doctype/delivery_note_item/delivery_note_item.json # erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
This commit is contained in:
7
.github/POSTGRES_COMPATIBILITY.md
vendored
7
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -170,6 +170,13 @@ audit of these fixes found four recurring mistakes:
|
||||
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
|
||||
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
|
||||
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
|
||||
- **Collation-dependent pick (text columns)** — `Max()`/`Min()` over text is a *sort*, and the two
|
||||
engines sort text differently: MariaDB's `utf8mb4` collations fold case, PostgreSQL (as CI runs
|
||||
it) orders by byte value. `MAX('abc', 'ABD')` is `ABD` on MariaDB and `abc` on PostgreSQL. So a
|
||||
`Max()` over a text column that varies **in case** within its group is a live P2 divergence, not
|
||||
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
|
||||
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
|
||||
MariaDB on case. Fix: take a representative row rather than sorting text.
|
||||
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
|
||||
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
|
||||
average for a rate. A blind `Max` can understate urgency or overstate a figure.
|
||||
|
||||
22
.github/helper/install.sh
vendored
22
.github/helper/install.sh
vendored
@@ -6,7 +6,27 @@ cd ~ || exit
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
frappecommitish=${FRAPPE_BRANCH:-}
|
||||
|
||||
# A stacked pull request targets another erpnext branch, which has no counterpart in frappe.
|
||||
# Fall back to develop so the bench is still installed. An explicit FRAPPE_BRANCH is trusted as
|
||||
# given, since it can be a commit sha rather than a branch.
|
||||
if [ -z "$frappecommitish" ]; then
|
||||
frappecommitish=$githubbranch
|
||||
|
||||
# git ls-remote --exit-code reports 2 for a branch that is not there and 128 for a remote it
|
||||
# could not reach. Only the first one is proof of absence; keep the branch on anything else so
|
||||
# a flaky probe cannot install an unrelated frappe.
|
||||
probe=0
|
||||
git ls-remote --exit-code --heads "https://github.com/${frappeuser}/frappe" "$frappecommitish" >/dev/null 2>&1 || probe=$?
|
||||
|
||||
if [ "$probe" -eq 2 ]; then
|
||||
echo "frappe has no branch ${frappecommitish}, falling back to develop"
|
||||
frappecommitish=develop
|
||||
elif [ "$probe" -ne 0 ]; then
|
||||
echo "could not reach frappe to check for branch ${frappecommitish} (git ls-remote exited ${probe}), keeping it"
|
||||
fi
|
||||
fi
|
||||
db_host=${DB_HOST:-"127.0.0.1"}
|
||||
db_user_host=${DB_USER_HOST:-"localhost"}
|
||||
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
|
||||
|
||||
25
.github/workflows/patch.yml
vendored
25
.github/workflows/patch.yml
vendored
@@ -171,7 +171,30 @@ jobs:
|
||||
update_to_version 16 3.14
|
||||
|
||||
echo "Updating to latest version"
|
||||
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
|
||||
fallback_to_develop=0
|
||||
if [ -n "${GITHUB_BASE_REF:-}" ]; then
|
||||
frappe_ref="refs/heads/$GITHUB_BASE_REF"
|
||||
fallback_to_develop=1
|
||||
elif [ "${GITHUB_REF_TYPE:-}" = "branch" ]; then
|
||||
frappe_ref="$GITHUB_REF"
|
||||
fallback_to_develop=1
|
||||
elif [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
frappe_ref="$GITHUB_REF"
|
||||
else
|
||||
echo "Unsupported GitHub ref type: '${GITHUB_REF_TYPE:-unset}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ls_remote_status=0
|
||||
git -C "apps/frappe" ls-remote --exit-code upstream "$frappe_ref" >/dev/null \
|
||||
|| ls_remote_status=$?
|
||||
if [ "$ls_remote_status" -eq 2 ] && [ "$fallback_to_develop" -eq 1 ]; then
|
||||
echo "frappe has no '$frappe_ref'; falling back to develop"
|
||||
frappe_ref=refs/heads/develop
|
||||
elif [ "$ls_remote_status" -ne 0 ]; then
|
||||
exit "$ls_remote_status"
|
||||
fi
|
||||
git -C "apps/frappe" fetch --depth 1 upstream "$frappe_ref"
|
||||
git -C "apps/frappe" checkout -q -f FETCH_HEAD
|
||||
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
|
||||
|
||||
|
||||
@@ -23,3 +23,5 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
with:
|
||||
hidden-po-files: eo.po
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ erpnext/accounts/ @ruthra-kumar
|
||||
erpnext/assets/ @khushi8112
|
||||
erpnext/regional @ruthra-kumar
|
||||
erpnext/selling @ruthra-kumar
|
||||
banking/ @nikkothari22
|
||||
|
||||
erpnext/buying/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi
|
||||
|
||||
@@ -19,13 +19,22 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import _ from "@/lib/translate"
|
||||
import { selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useFrappeGetDocList } from "frappe-react-sdk"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
|
||||
const { data: companies, error } = useFrappeGetDocList("Company", {
|
||||
limit: 0,
|
||||
fields: ["name"],
|
||||
}, 'company_list', {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const options = companies?.map((company: { name: string }) => company.name) || []
|
||||
|
||||
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
|
||||
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
|
||||
@@ -42,6 +51,10 @@ const CompanySelector = ({ onChange }: { onChange?: (company: string) => void })
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
|
||||
return (<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
|
||||
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '', undefined, {
|
||||
getOnInit: true,
|
||||
})
|
||||
|
||||
export const useCurrentCompany = () => {
|
||||
const selectedCompany = useAtomValue(selectedCompanyAtom)
|
||||
|
||||
@@ -6,88 +6,148 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||
get_outstanding_reference_documents,
|
||||
get_payment_entry,
|
||||
)
|
||||
from erpnext.utilities.bulk_transaction import transaction_processing
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_payment_entries(
|
||||
grouped_invoices: str | list | None = None,
|
||||
ungrouped_invoices: str | list | None = None,
|
||||
):
|
||||
def create_payment_entries(invoices: str | list | None = None):
|
||||
"""Create draft Payment Entries from AP report invoice selection."""
|
||||
frappe.has_permission("Payment Entry", "create", throw=True)
|
||||
|
||||
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
|
||||
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
|
||||
|
||||
if not grouped_invoices and not ungrouped_invoices:
|
||||
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
|
||||
if not names:
|
||||
frappe.throw(_("No Purchase Invoices selected"))
|
||||
|
||||
if ungrouped_invoices:
|
||||
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
|
||||
transaction_processing(data, "Purchase Invoice", "Payment Entry")
|
||||
payable, excluded = _partition_payable_invoices(names)
|
||||
if not payable:
|
||||
frappe.throw(_("None of the selected invoices are payable"))
|
||||
|
||||
if grouped_invoices:
|
||||
groups = {}
|
||||
for d in grouped_invoices:
|
||||
key = (d["supplier"], d["party_account"])
|
||||
groups.setdefault(
|
||||
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
|
||||
)["vouchers"].append(d["voucher_no"])
|
||||
# invoices sharing a (supplier, payable account) are combined into one Payment Entry
|
||||
groups = {}
|
||||
for d in payable:
|
||||
key = (d["supplier"], d["party_account"])
|
||||
groups.setdefault(
|
||||
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
|
||||
)["vouchers"].append(d["voucher_no"])
|
||||
|
||||
frappe.msgprint(
|
||||
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
|
||||
)
|
||||
frappe.enqueue(
|
||||
make_grouped_payment_entries,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
groups=list(groups.values()),
|
||||
)
|
||||
|
||||
|
||||
def make_grouped_payment_entries(groups):
|
||||
created, failed = 0, 0
|
||||
|
||||
for group in groups:
|
||||
supplier = group["supplier"]
|
||||
try:
|
||||
frappe.db.savepoint("bulk_pe")
|
||||
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
|
||||
if not pe:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
failed += 1
|
||||
frappe.log_error(
|
||||
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
|
||||
message=_(
|
||||
"No outstanding invoices found for the selected vouchers in account {0}"
|
||||
).format(group["party_account"]),
|
||||
)
|
||||
continue
|
||||
|
||||
pe.flags.ignore_validate = True
|
||||
pe.set_title_field()
|
||||
pe.insert(ignore_mandatory=True)
|
||||
for group in groups.values():
|
||||
if _create_payment_entry(group):
|
||||
created += 1
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
else:
|
||||
failed += 1
|
||||
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
|
||||
|
||||
message = _("Created {0} draft Grouped Payment Entries").format(created)
|
||||
|
||||
message = _("Created {0} draft Payment Entries").format(created)
|
||||
if excluded:
|
||||
message += " — " + _("{0} excluded (not payable)").format(len(excluded))
|
||||
if failed:
|
||||
message += " — " + _("{0} skipped (see Error Log)").format(failed)
|
||||
message += " — " + _("{0} failed (see Error Log)").format(failed)
|
||||
frappe.msgprint(message, title=_("Bulk Payment Entries"), indicator="green")
|
||||
|
||||
frappe.publish_realtime(
|
||||
"msgprint",
|
||||
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
|
||||
user=frappe.session.user,
|
||||
after_commit=True,
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payable_invoices(invoices: str | list | None = None):
|
||||
"""Return the live payable subset of the selected invoices for the report dialog."""
|
||||
frappe.has_permission("Payment Entry", "create", throw=True)
|
||||
|
||||
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
|
||||
payable, excluded = _partition_payable_invoices(names)
|
||||
|
||||
currency = None
|
||||
if payable:
|
||||
company = frappe.get_cached_value("Purchase Invoice", payable[0]["voucher_no"], "company")
|
||||
currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
|
||||
return {"payable": payable, "excluded": excluded, "currency": currency}
|
||||
|
||||
|
||||
def _partition_payable_invoices(names):
|
||||
"""Split submitted Purchase Invoices into payable ones and excluded ones (with reason).
|
||||
|
||||
Returns are debit notes, internal transfers are inter-company, and non-positive
|
||||
outstanding means already settled — none are valid targets for a supplier payment.
|
||||
"""
|
||||
if not names:
|
||||
return [], []
|
||||
|
||||
rows = frappe.get_list(
|
||||
"Purchase Invoice",
|
||||
filters={"name": ["in", names], "docstatus": 1},
|
||||
fields=[
|
||||
"name",
|
||||
"supplier",
|
||||
"credit_to",
|
||||
"outstanding_amount",
|
||||
"conversion_rate",
|
||||
"is_return",
|
||||
"is_internal_supplier",
|
||||
],
|
||||
limit_page_length=0,
|
||||
)
|
||||
|
||||
payable, excluded = [], []
|
||||
for r in rows:
|
||||
if r.is_return:
|
||||
excluded.append({"voucher_no": r.name, "reason": _("Debit Note")})
|
||||
elif r.is_internal_supplier:
|
||||
excluded.append({"voucher_no": r.name, "reason": _("Internal Transfer")})
|
||||
elif flt(r.outstanding_amount) <= 0:
|
||||
excluded.append({"voucher_no": r.name, "reason": _("Already Paid")})
|
||||
else:
|
||||
payable.append(
|
||||
{
|
||||
"voucher_no": r.name,
|
||||
"supplier": r.supplier,
|
||||
"party_account": r.credit_to,
|
||||
"outstanding": flt(r.outstanding_amount) * flt(r.conversion_rate or 1),
|
||||
}
|
||||
)
|
||||
|
||||
# names not returned were cancelled/deleted or no longer readable after the report loaded
|
||||
found = {r.name for r in rows}
|
||||
for name in names:
|
||||
if name not in found:
|
||||
excluded.append({"voucher_no": name, "reason": _("Not available")})
|
||||
|
||||
return payable, excluded
|
||||
|
||||
|
||||
def _create_payment_entry(group):
|
||||
supplier = group["supplier"]
|
||||
try:
|
||||
frappe.db.savepoint("bulk_pe")
|
||||
if len(group["vouchers"]) == 1:
|
||||
pe = _build_single_payment_entry(group["vouchers"][0])
|
||||
else:
|
||||
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
|
||||
|
||||
if not pe:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
frappe.log_error(
|
||||
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
|
||||
message=_("No outstanding amount for the selected invoice(s)."),
|
||||
)
|
||||
return False
|
||||
|
||||
pe.flags.ignore_validate = True
|
||||
pe.set_title_field()
|
||||
pe.insert(ignore_mandatory=True)
|
||||
return True
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="bulk_pe")
|
||||
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
|
||||
return False
|
||||
|
||||
|
||||
def _build_single_payment_entry(name):
|
||||
pe = get_payment_entry("Purchase Invoice", name)
|
||||
# guard against a stale report row: nothing to allocate means the invoice is already settled
|
||||
if not pe.references or not any(flt(r.allocated_amount) for r in pe.references):
|
||||
return None
|
||||
return pe
|
||||
|
||||
|
||||
def _build_grouped_payment_entry(supplier, party_account, names):
|
||||
name_set = set(names)
|
||||
pe = get_payment_entry("Purchase Invoice", names[0])
|
||||
pe.set("references", [])
|
||||
|
||||
@@ -101,8 +161,9 @@ def _build_grouped_payment_entry(supplier, party_account, names):
|
||||
}
|
||||
)
|
||||
|
||||
# get_negative_outstanding_invoices ignores the vouchers filter, so bound refs to the selection
|
||||
for r in refs:
|
||||
if r.voucher_type != "Purchase Invoice":
|
||||
if r.voucher_type != "Purchase Invoice" or r.voucher_no not in name_set:
|
||||
continue
|
||||
pe.append(
|
||||
"references",
|
||||
|
||||
@@ -730,6 +730,8 @@ def get_company_default_account_fields():
|
||||
"default_discount_account": "Default Payment Discount Account",
|
||||
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
|
||||
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
|
||||
"exchange_gain_account": "Exchange Gain Account",
|
||||
"exchange_loss_account": "Exchange Loss Account",
|
||||
"unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
|
||||
"round_off_account": "Round Off Account",
|
||||
"default_deferred_revenue_account": "Default Deferred Revenue Account",
|
||||
|
||||
@@ -179,6 +179,9 @@
|
||||
},
|
||||
"Impairment": {
|
||||
"account_category": "Operating Expenses"
|
||||
},
|
||||
"Exchange Loss": {
|
||||
"account_category": "Operating Expenses"
|
||||
}
|
||||
},
|
||||
"root_type": "Expense"
|
||||
@@ -196,6 +199,10 @@
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Indirect Income": {
|
||||
"Exchange Gain": {
|
||||
"account_type": "Income Account",
|
||||
"account_category": "Other Operating Income"
|
||||
},
|
||||
"account_type": "Income Account",
|
||||
"is_group": 1
|
||||
},
|
||||
|
||||
@@ -138,6 +138,7 @@ def get():
|
||||
_("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"},
|
||||
_("Impairment"): {"account_category": "Operating Expenses"},
|
||||
_("Tax Expense"): {"account_category": "Tax Expense"},
|
||||
_("Exchange Loss"): {"account_category": "Operating Expenses"},
|
||||
},
|
||||
"root_type": "Expense",
|
||||
},
|
||||
@@ -149,6 +150,7 @@ def get():
|
||||
_("Indirect Income"): {
|
||||
_("Interest Income"): {"account_category": "Investment Income"},
|
||||
_("Interest on Fixed Deposits"): {"account_category": "Investment Income"},
|
||||
_("Exchange Gain"): {"account_category": "Other Operating Income"},
|
||||
"is_group": 1,
|
||||
},
|
||||
"root_type": "Income",
|
||||
|
||||
@@ -233,6 +233,7 @@ def get():
|
||||
},
|
||||
_("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"},
|
||||
_("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"},
|
||||
_("Exchange Loss"): {"account_number": "5226", "account_category": "Operating Expenses"},
|
||||
"account_number": "5200",
|
||||
},
|
||||
"root_type": "Expense",
|
||||
@@ -250,6 +251,10 @@ def get():
|
||||
"account_number": "4220",
|
||||
"account_category": "Investment Income",
|
||||
},
|
||||
_("Exchange Gain"): {
|
||||
"account_number": "4230",
|
||||
"account_category": "Other Operating Income",
|
||||
},
|
||||
"is_group": 1,
|
||||
"account_number": "4200",
|
||||
},
|
||||
|
||||
@@ -275,6 +275,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
.join(overdue_payment)
|
||||
.on(overdue_payment.parent == dunning.name)
|
||||
.select(dunning.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(dunning.status == state)
|
||||
& (dunning.docstatus != 2)
|
||||
|
||||
@@ -123,6 +123,41 @@ class TestDunning(ERPNextTestSuite):
|
||||
self.assertEqual(sales_invoice.status, "Overdue")
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
|
||||
"""
|
||||
When an invoice has more than one overdue installment, its Dunning holds one
|
||||
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
|
||||
must resolve the Dunning without raising a TimestampMismatchError caused by the
|
||||
same Dunning being loaded and saved more than once.
|
||||
"""
|
||||
create_payment_terms_template_for_dunning()
|
||||
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
|
||||
sales_invoice = create_sales_invoice_against_cost_center(
|
||||
posting_date=add_days(today(), -15),
|
||||
qty=1,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
|
||||
sales_invoice.submit()
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
|
||||
# Two overdue installments -> two overdue payment rows for the same invoice.
|
||||
self.assertEqual(len(dunning.overdue_payments), 2)
|
||||
dunning.submit()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
|
||||
pe.reference_no, pe.reference_date = "3", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
sales_invoice.reload()
|
||||
dunning.reload()
|
||||
self.assertEqual(sales_invoice.outstanding_amount, 0)
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_resolution_from_credit_note(self):
|
||||
"""
|
||||
Test that dunning is resolved when a credit note is issued against the original invoice.
|
||||
|
||||
@@ -235,7 +235,7 @@ Object.assign(erpnext.journal_entry, {
|
||||
lock_reversal_entry(frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.filter((field) => field.df.fieldname != "posting_date")
|
||||
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
@@ -184,6 +184,7 @@ class JournalEntryReferenceValidator:
|
||||
continue
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||
self._validate_block_invoice(invoice)
|
||||
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
|
||||
"""Payment booked against an invoice cannot exceed its outstanding amount."""
|
||||
@@ -197,3 +198,15 @@ class JournalEntryReferenceValidator:
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_block_invoice(self, invoice):
|
||||
"""Payment cannnot be booked against blocked Purchase Invoices"""
|
||||
if invoice.doctype != "Purchase Invoice":
|
||||
return
|
||||
|
||||
if invoice.invoice_is_blocked():
|
||||
frappe.throw(
|
||||
_("{0} {1} is blocked and on hold until {2}.").format(
|
||||
invoice.doctype, invoice.name, invoice.release_date
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, nowdate
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
|
||||
@@ -748,6 +748,69 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
|
||||
|
||||
def make_jv_against_purchase_invoice(self, invoice, amount=100):
|
||||
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
|
||||
jv.accounts[0].party_type = "Supplier"
|
||||
jv.accounts[0].party = invoice.supplier
|
||||
jv.accounts[0].reference_type = "Purchase Invoice"
|
||||
jv.accounts[0].reference_name = invoice.name
|
||||
return jv
|
||||
|
||||
def test_jv_against_purchase_invoice_respects_hold_state(self):
|
||||
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
release_date = add_days(nowdate(), 10)
|
||||
|
||||
def never_held():
|
||||
return make_purchase_invoice()
|
||||
|
||||
def held_until_a_future_date():
|
||||
invoice = make_purchase_invoice()
|
||||
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
|
||||
return invoice
|
||||
|
||||
def held_without_a_release_date():
|
||||
invoice = make_purchase_invoice()
|
||||
invoice.block_invoice(hold_comment="Under dispute")
|
||||
return invoice
|
||||
|
||||
def held_until_a_date_that_has_passed():
|
||||
invoice = held_until_a_future_date()
|
||||
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
|
||||
return invoice
|
||||
|
||||
def unblocked_again():
|
||||
invoice = held_until_a_future_date()
|
||||
invoice.unblock_invoice()
|
||||
return invoice
|
||||
|
||||
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
|
||||
with self.subTest(build_invoice.__name__):
|
||||
jv = self.make_jv_against_purchase_invoice(build_invoice())
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is blocked and on hold until", jv.insert)
|
||||
|
||||
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
|
||||
with self.subTest(build_invoice.__name__):
|
||||
invoice = build_invoice()
|
||||
jv = self.make_jv_against_purchase_invoice(invoice)
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
|
||||
|
||||
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
|
||||
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
|
||||
def test_get_balance_places_difference_on_blank_row(self):
|
||||
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
|
||||
@@ -3045,13 +3045,11 @@ def set_paid_amount_and_received_amount(
|
||||
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
|
||||
if bank and company_currency != bank.account_currency:
|
||||
# doc currency can be different from bank currency
|
||||
posting_date = doc.get("posting_date") or doc.get("transaction_date")
|
||||
conversion_rate = get_exchange_rate(
|
||||
bank.account_currency, party_account_currency, posting_date
|
||||
)
|
||||
conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
|
||||
received_amount = paid_amount / conversion_rate
|
||||
else:
|
||||
received_amount = paid_amount * doc.get("conversion_rate", 1)
|
||||
conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
|
||||
received_amount = paid_amount * conversion_rate
|
||||
|
||||
# if payment type is pay, then paid amount and received amount are swapped
|
||||
if payment_type == "Pay":
|
||||
|
||||
@@ -950,6 +950,61 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
|
||||
self.assertEqual(outstanding_amount, 0)
|
||||
|
||||
def test_exchange_gain_loss_split_accounts(self):
|
||||
gain_account = create_account(
|
||||
account_name="_Test Exchange Gain",
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
company="_Test Company",
|
||||
)
|
||||
loss_account = create_account(
|
||||
account_name="_Test Exchange Loss",
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
company="_Test Company",
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "exchange_gain_account", gain_account)
|
||||
frappe.db.set_value("Company", "_Test Company", "exchange_loss_account", loss_account)
|
||||
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_gain_account", "")
|
||||
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_loss_account", "")
|
||||
|
||||
si_gain = create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
debit_to="_Test Receivable USD - _TC",
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
)
|
||||
pe_gain = get_payment_entry("Sales Invoice", si_gain.name, bank_account="_Test Bank USD - _TC")
|
||||
pe_gain.reference_no = "1"
|
||||
pe_gain.reference_date = "2016-01-01"
|
||||
pe_gain.source_exchange_rate = 55
|
||||
pe_gain.save()
|
||||
self.assertEqual(pe_gain.references[0].exchange_gain_loss, 500)
|
||||
pe_gain.submit()
|
||||
|
||||
self.assertEqual(self.get_gain_loss_journal_account(pe_gain.name), gain_account)
|
||||
|
||||
si_loss = create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
debit_to="_Test Receivable USD - _TC",
|
||||
currency="USD",
|
||||
conversion_rate=55,
|
||||
)
|
||||
pe_loss = get_payment_entry("Sales Invoice", si_loss.name, bank_account="_Test Bank USD - _TC")
|
||||
pe_loss.reference_no = "2"
|
||||
pe_loss.reference_date = "2016-01-01"
|
||||
pe_loss.source_exchange_rate = 50
|
||||
pe_loss.save()
|
||||
self.assertEqual(pe_loss.references[0].exchange_gain_loss, -500)
|
||||
pe_loss.submit()
|
||||
|
||||
self.assertEqual(self.get_gain_loss_journal_account(pe_loss.name), loss_account)
|
||||
|
||||
def get_gain_loss_journal_account(self, payment_entry_name: str) -> str | None:
|
||||
return frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{"reference_type": "Payment Entry", "reference_name": payment_entry_name, "docstatus": 1},
|
||||
"account",
|
||||
)
|
||||
|
||||
def test_payment_entry_against_sales_invoice_with_cost_centre(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec
|
||||
is_any_doc_running,
|
||||
)
|
||||
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
|
||||
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
|
||||
from erpnext.accounts.utils import (
|
||||
QueryPaymentLedger,
|
||||
create_gain_loss_journal,
|
||||
@@ -485,9 +486,6 @@ class PaymentReconciliation(Document):
|
||||
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
|
||||
)
|
||||
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
|
||||
default_exchange_gain_loss_account = frappe.get_cached_value(
|
||||
"Company", self.company, "exchange_gain_loss_account"
|
||||
)
|
||||
|
||||
entries = []
|
||||
for pay in args.get("payments"):
|
||||
@@ -507,7 +505,10 @@ class PaymentReconciliation(Document):
|
||||
pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name"))
|
||||
|
||||
res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"])
|
||||
res.difference_account = default_exchange_gain_loss_account
|
||||
is_gain = (
|
||||
res.difference_amount > 0 if self.party_type == "Customer" else res.difference_amount < 0
|
||||
)
|
||||
res.difference_account = get_exchange_gain_loss_account(self.company, is_gain)
|
||||
res.exchange_rate = inv.get("exchange_rate")
|
||||
res.update({"gain_loss_posting_date": pay.get("posting_date")})
|
||||
if not pay.get("is_advance"):
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
|
||||
from frappe.utils.data import getdate as convert_to_date
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
@@ -187,6 +188,150 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
)
|
||||
return je
|
||||
|
||||
def setup_split_exchange_accounts(self):
|
||||
gain_account = create_account(
|
||||
account_name="_Test PR Split Exchange Gain",
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
company=self.company,
|
||||
)
|
||||
loss_account = create_account(
|
||||
account_name="_Test PR Split Exchange Loss",
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
company=self.company,
|
||||
)
|
||||
frappe.db.set_value("Company", self.company, "exchange_gain_account", gain_account)
|
||||
frappe.db.set_value("Company", self.company, "exchange_loss_account", loss_account)
|
||||
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_gain_account", "")
|
||||
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_loss_account", "")
|
||||
return gain_account, loss_account
|
||||
|
||||
def create_foreign_currency_sales_invoice(self, conversion_rate):
|
||||
si = self.create_sales_invoice(
|
||||
qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
si.customer = self.customer_usd
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = conversion_rate
|
||||
si.debit_to = self.debtors_usd
|
||||
si.save().submit()
|
||||
return si
|
||||
|
||||
def create_foreign_currency_journal_payment(self, debtors_account, exchange_rate):
|
||||
je = self.create_journal_entry(self.bank, debtors_account, 100, nowdate())
|
||||
je.multi_currency = 1
|
||||
je.accounts[0].exchange_rate = 1
|
||||
je.accounts[0].credit_in_account_currency = 0
|
||||
je.accounts[0].credit = 0
|
||||
je.accounts[0].debit_in_account_currency = 100 * exchange_rate
|
||||
je.accounts[0].debit = 100 * exchange_rate
|
||||
je.accounts[1].party_type = "Customer"
|
||||
je.accounts[1].party = self.customer_usd
|
||||
je.accounts[1].exchange_rate = exchange_rate
|
||||
je.accounts[1].credit_in_account_currency = 100
|
||||
je.accounts[1].credit = 100 * exchange_rate
|
||||
je.accounts[1].debit_in_account_currency = 0
|
||||
je.accounts[1].debit = 0
|
||||
je.save()
|
||||
je.submit()
|
||||
return je
|
||||
|
||||
def test_voucher_outstanding_metadata_comes_from_one_ledger_entry(self):
|
||||
"""cost_center and remarks must describe the same Payment Ledger Entry.
|
||||
|
||||
A voucher can post several ledger entries for one party with different cost centers and
|
||||
remarks. Aggregating each column on its own can pair one entry's cost center with another's
|
||||
remarks -- a row that was never posted -- and because Max() over text is a sort, MariaDB and
|
||||
PostgreSQL can pick differently on top of that.
|
||||
"""
|
||||
from erpnext.accounts.utils import QueryPaymentLedger
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = nowdate()
|
||||
je.company = self.company
|
||||
je.user_remark = "aaa base remark"
|
||||
for cost_center, remark, amount in (
|
||||
(self.main_cc, "aaa main line", 100),
|
||||
(self.sub_cc, "zzz sub line", 50),
|
||||
):
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"cost_center": cost_center,
|
||||
"user_remark": remark,
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts", {"account": self.cash, "cost_center": self.main_cc, "credit_in_account_currency": 150}
|
||||
)
|
||||
je.save()
|
||||
je.submit()
|
||||
|
||||
posted = {
|
||||
(row.cost_center, row.remarks)
|
||||
for row in frappe.get_all(
|
||||
"Payment Ledger Entry",
|
||||
filters={"voucher_no": je.name, "delinked": 0},
|
||||
fields=["cost_center", "remarks"],
|
||||
)
|
||||
}
|
||||
self.assertGreater(len(posted), 1, "fixture must post more than one ledger entry to be meaningful")
|
||||
|
||||
ledger = QueryPaymentLedger()
|
||||
rows = ledger.get_voucher_outstandings(
|
||||
vouchers=[frappe._dict(voucher_type="Journal Entry", voucher_no=je.name)]
|
||||
)
|
||||
self.assertTrue(rows)
|
||||
|
||||
for row in rows:
|
||||
self.assertIn((row.cost_center, row.remarks), posted)
|
||||
|
||||
def test_voucher_outstanding_splits_by_party_account(self):
|
||||
"""A voucher posting to two party accounts must report each account separately.
|
||||
|
||||
account is the join key between the amount and outstanding CTEs. Selecting Max(account)
|
||||
while grouping without it made that key an aggregate over two different row sets, so the two
|
||||
sides could pick different accounts, the join would miss and the outstanding come back NULL.
|
||||
It also summed amounts across accounts that need not share a currency.
|
||||
"""
|
||||
from erpnext.accounts.utils import QueryPaymentLedger
|
||||
|
||||
second_receivable = "_Test Receivable - _TC"
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = nowdate()
|
||||
je.company = self.company
|
||||
je.user_remark = "two receivable accounts"
|
||||
for account, amount in ((self.debit_to, 100), (second_receivable, 60)):
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": account,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"cost_center": self.main_cc,
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts", {"account": self.cash, "cost_center": self.main_cc, "credit_in_account_currency": 160}
|
||||
)
|
||||
je.save()
|
||||
je.submit()
|
||||
|
||||
rows = QueryPaymentLedger().get_voucher_outstandings(
|
||||
vouchers=[frappe._dict(voucher_type="Journal Entry", voucher_no=je.name)]
|
||||
)
|
||||
by_account = {row.account: row for row in rows}
|
||||
|
||||
self.assertEqual(set(by_account), {self.debit_to, second_receivable})
|
||||
self.assertEqual(flt(by_account[self.debit_to].invoice_amount), 100)
|
||||
self.assertEqual(flt(by_account[second_receivable].invoice_amount), 60)
|
||||
for row in rows:
|
||||
self.assertIsNotNone(row.outstanding)
|
||||
|
||||
def test_filter_min_max(self):
|
||||
# check filter condition minimum and maximum amount
|
||||
self.create_sales_invoice(qty=1, rate=300)
|
||||
@@ -859,6 +1004,85 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
|
||||
)
|
||||
|
||||
def test_exchange_gain_loss_split_default_account(self):
|
||||
gain_account, loss_account = self.setup_split_exchange_accounts()
|
||||
|
||||
self.create_foreign_currency_sales_invoice(conversion_rate=80)
|
||||
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=85)
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.party = self.customer_usd
|
||||
pr.receivable_payable_account = self.debtors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
self.assertEqual(pr.allocation[0].difference_amount, 500)
|
||||
self.assertEqual(pr.allocation[0].difference_account, gain_account)
|
||||
pr.reconcile()
|
||||
|
||||
self.create_foreign_currency_sales_invoice(conversion_rate=85)
|
||||
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.party = self.customer_usd
|
||||
pr.receivable_payable_account = self.debtors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
self.assertEqual(pr.allocation[0].difference_amount, -500)
|
||||
self.assertEqual(pr.allocation[0].difference_account, loss_account)
|
||||
|
||||
def test_payment_reconciliation_difference_account_override(self):
|
||||
_, loss_account = self.setup_split_exchange_accounts()
|
||||
override_account = create_account(
|
||||
account_name="_Test PR Override Exchange Account",
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
company=self.company,
|
||||
)
|
||||
|
||||
si = self.create_foreign_currency_sales_invoice(conversion_rate=85)
|
||||
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.party = self.customer_usd
|
||||
pr.receivable_payable_account = self.debtors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Default, computed from the split company fields, is pre-filled onto the row...
|
||||
self.assertEqual(pr.allocation[0].difference_amount, -500)
|
||||
self.assertEqual(pr.allocation[0].difference_account, loss_account)
|
||||
|
||||
# ...but the user can override it in the "Select Difference Account" dialog before reconciling,
|
||||
# and that explicit choice must be what actually gets booked, not the computed default.
|
||||
pr.allocation[0].difference_account = override_account
|
||||
pr.reconcile()
|
||||
|
||||
jea_parent = frappe.db.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"account": self.debtors_usd, "docstatus": 1, "reference_name": si.name, "credit": 500},
|
||||
fields=["parent"],
|
||||
)[0]
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
|
||||
)
|
||||
|
||||
gain_loss_line_account = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{"parent": jea_parent.parent, "account": ["!=", self.debtors_usd]},
|
||||
"account",
|
||||
)
|
||||
self.assertEqual(gain_loss_line_account, override_account)
|
||||
|
||||
def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
|
||||
# Make Sale Invoice
|
||||
si = self.create_sales_invoice(
|
||||
@@ -2402,6 +2626,86 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
|
||||
transaction_date = nowdate()
|
||||
self.supplier = "_Test Supplier USD"
|
||||
amount = 100
|
||||
department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
|
||||
|
||||
# Pay USD 100 at an exchange rate of 90.
|
||||
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
pe.payment_type = "Pay"
|
||||
pe.party_type = "Supplier"
|
||||
pe.party = self.supplier
|
||||
pe.paid_from = self.cash
|
||||
pe.paid_from_account_currency = "INR"
|
||||
pe.target_exchange_rate = 90
|
||||
pe.paid_amount = 90 * amount
|
||||
pe.received_amount = amount
|
||||
pe.paid_to = self.creditors_usd
|
||||
pe.paid_to_account_currency = "USD"
|
||||
pe.department = department
|
||||
pe = pe.save().submit()
|
||||
|
||||
# Receive USD 100 from the supplier at an exchange rate of 100.
|
||||
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
reverse_pe.payment_type = "Receive"
|
||||
reverse_pe.party_type = "Supplier"
|
||||
reverse_pe.party = self.supplier
|
||||
reverse_pe.paid_from = self.creditors_usd
|
||||
reverse_pe.paid_from_account_currency = "USD"
|
||||
reverse_pe.source_exchange_rate = 100
|
||||
reverse_pe.paid_amount = amount
|
||||
reverse_pe.received_amount = 100 * amount
|
||||
reverse_pe.paid_to = self.cash
|
||||
reverse_pe.paid_to_account_currency = "INR"
|
||||
reverse_pe.department = department
|
||||
reverse_pe = reverse_pe.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation(party_is_customer=False)
|
||||
pr.party = self.supplier
|
||||
pr.receivable_payable_account = self.creditors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
for row in pr.allocation:
|
||||
row.department = department
|
||||
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
|
||||
pr.reconcile()
|
||||
|
||||
gain_loss_journal = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{
|
||||
"reference_type": reverse_pe.doctype,
|
||||
"reference_name": reverse_pe.name,
|
||||
"party": self.supplier,
|
||||
"docstatus": 1,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
party_row = frappe.db.get_value(
|
||||
"Journal Entry Account",
|
||||
{"parent": gain_loss_journal, "party": self.supplier},
|
||||
["debit", "credit"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(party_row.debit), 1000)
|
||||
self.assertEqual(flt(party_row.credit), 0)
|
||||
|
||||
party_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
|
||||
"account": self.creditors_usd,
|
||||
"party": self.supplier,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["debit", "credit"],
|
||||
)
|
||||
self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
|
||||
|
||||
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
|
||||
transaction_date = nowdate()
|
||||
customer = self.customer_usd
|
||||
|
||||
@@ -37,6 +37,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
|
||||
frm.set_intro(__("Failure: {0}", [frm.doc.failed_reason]), "red");
|
||||
}
|
||||
|
||||
let sending_email = false;
|
||||
|
||||
if (
|
||||
frm.doc.payment_request_type == "Inward" &&
|
||||
frm.doc.payment_channel !== "Phone" &&
|
||||
@@ -45,16 +47,16 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
|
||||
frm.doc.docstatus == 1
|
||||
) {
|
||||
frm.add_custom_button(__("Resend Payment Email"), function () {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email",
|
||||
args: { docname: frm.doc.name },
|
||||
freeze: true,
|
||||
freeze_message: __("Sending"),
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
frappe.msgprint(__("Message Sent"));
|
||||
}
|
||||
},
|
||||
if (sending_email) {
|
||||
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
|
||||
return;
|
||||
}
|
||||
sending_email = true;
|
||||
frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
|
||||
frm.call("resend_payment_email").then((r) => {
|
||||
const msg = !r.exc ? __("Email Sent") : __("Email couldn't be sent.");
|
||||
frappe.show_alert({ message: msg, indicator: !r.exc ? "green" : "red" });
|
||||
sending_email = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -589,6 +589,18 @@ class PaymentRequest(Document):
|
||||
|
||||
return payment_entry
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resend_payment_email(self):
|
||||
if not (
|
||||
self.docstatus == 1
|
||||
and self.payment_request_type == "Inward"
|
||||
and self.payment_channel != "Phone"
|
||||
and self.status not in ["Initiated", "Paid"]
|
||||
):
|
||||
frappe.throw(_("Payment Link couldn't be sent."))
|
||||
|
||||
self.send_email()
|
||||
|
||||
def send_email(self):
|
||||
"""send email with payment link"""
|
||||
email_args = {
|
||||
@@ -606,11 +618,14 @@ class PaymentRequest(Document):
|
||||
)
|
||||
],
|
||||
}
|
||||
job_id = f"send_payment_email::{self.name}"
|
||||
enqueue(
|
||||
method=frappe.sendmail,
|
||||
queue="short",
|
||||
timeout=300,
|
||||
is_async=True,
|
||||
job_id=job_id,
|
||||
deduplicate=True,
|
||||
enqueue_after_commit=True,
|
||||
**email_args,
|
||||
)
|
||||
@@ -1115,11 +1130,6 @@ def get_print_format_list(ref_doctype: str):
|
||||
return {"print_format": print_format_list}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def resend_payment_email(docname: str):
|
||||
return frappe.get_doc("Payment Request", docname).send_email()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_payment_entry(docname: str):
|
||||
doc = frappe.get_doc("Payment Request", docname)
|
||||
|
||||
@@ -6,16 +6,21 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_days, flt, formatdate, getdate
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
|
||||
|
||||
class PeriodClosingVoucher(AccountsController):
|
||||
@@ -45,6 +50,14 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.check_closing_account_type()
|
||||
self.check_closing_account_currency()
|
||||
self.validate_accounts_not_frozen()
|
||||
|
||||
def validate_accounts_not_frozen(self, for_cancellation=False):
|
||||
posting_date = self.period_end_date
|
||||
if for_cancellation and is_immutable_ledger_enabled():
|
||||
posting_date = getdate()
|
||||
|
||||
check_freezing_date(posting_date, self.company)
|
||||
|
||||
def validate_start_and_end_date(self):
|
||||
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
|
||||
@@ -132,6 +145,121 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if account_currency != company_currency:
|
||||
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
|
||||
|
||||
def before_submit(self):
|
||||
if not self.has_stock_transactions():
|
||||
return
|
||||
|
||||
self.validate_stock_accounts_balance()
|
||||
self.validate_stock_closing_entry()
|
||||
|
||||
def has_stock_transactions(self):
|
||||
if not is_perpetual_inventory_enabled(self.company):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"company": self.company,
|
||||
"is_cancelled": 0,
|
||||
"posting_date": ("<=", self.period_end_date),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_stock_accounts_balance(self):
|
||||
precision = frappe.get_precision("GL Entry", "debit")
|
||||
account_balance = flt(self.get_stock_accounts_balance(), precision)
|
||||
stock_value = flt(
|
||||
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
|
||||
)
|
||||
|
||||
if account_balance == stock_value:
|
||||
return
|
||||
|
||||
currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
frappe.throw(
|
||||
_(
|
||||
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
|
||||
).format(
|
||||
frappe.bold(fmt_money(account_balance, currency=currency)),
|
||||
frappe.bold(fmt_money(stock_value, currency=currency)),
|
||||
frappe.bold(formatdate(self.period_end_date)),
|
||||
),
|
||||
title=_("Stock Value Mismatch"),
|
||||
)
|
||||
|
||||
def get_stock_accounts_balance(self):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
stock_accounts = (
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(
|
||||
(account.account_type == "Stock")
|
||||
& (account.company == self.company)
|
||||
& (account.is_group == 0)
|
||||
)
|
||||
)
|
||||
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit - gle.credit))
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (gle.posting_date <= self.period_end_date)
|
||||
& gle.account.isin(stock_accounts)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(balance[0][0]) if balance else 0.0
|
||||
|
||||
def validate_stock_closing_entry(self):
|
||||
closing_entry = frappe.db.get_value(
|
||||
"Stock Closing Entry",
|
||||
apply_unscoped_filters(
|
||||
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
|
||||
),
|
||||
["name", "status", "modified"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not closing_entry:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry Required"),
|
||||
)
|
||||
|
||||
if closing_entry.status != "Completed":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry In Progress"),
|
||||
)
|
||||
|
||||
self.validate_stock_closing_entry_is_fresh(closing_entry)
|
||||
|
||||
def validate_stock_closing_entry_is_fresh(self, closing_entry):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
last_change = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Max(sle.modified))
|
||||
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
|
||||
).run()
|
||||
|
||||
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
|
||||
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
|
||||
title=_("Stock Closing Entry Outdated"),
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
@@ -149,6 +277,7 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Process Period Closing Voucher",
|
||||
)
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.validate_accounts_not_frozen(for_cancellation=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
@@ -386,6 +386,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
|
||||
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
|
||||
|
||||
def test_stock_validations_before_period_closing(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Stock Closing Entry": [
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
"label": "Warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": pcv.period_start_date,
|
||||
"to_date": pcv.period_end_date,
|
||||
"warehouse": "Stores - TPC",
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name},
|
||||
["name", "stock_value_difference"],
|
||||
as_dict=1,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
"Test PCV Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TPCVB.####",
|
||||
},
|
||||
)
|
||||
se1 = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=200,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-06-15",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
from_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2022-04-01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
)
|
||||
self.assertEqual(flt(stock_value_difference, 2), -750.0)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"frozen",
|
||||
make_stock_entry,
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
|
||||
|
||||
def test_period_closing_blocks_stale_stock_closing_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def make_completed_stock_closing_entry(self, from_date, to_date):
|
||||
from unittest.mock import patch
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
return sce
|
||||
|
||||
def rebuild_stock_closing_balance(self, sce):
|
||||
sce.remove_stock_closing()
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -888,7 +889,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -240,10 +240,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
unblock_invoice() {
|
||||
const me = this;
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
|
||||
args: { name: me.frm.doc.name },
|
||||
callback: (r) => me.frm.reload_doc(),
|
||||
me.frm.call("unblock_invoice", null, () => {
|
||||
me.frm.reload_doc();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,15 +292,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
this.dialog.set_primary_action(__("Save"), function () {
|
||||
const dialog_data = me.dialog.get_values();
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
|
||||
args: {
|
||||
name: me.frm.doc.name,
|
||||
me.frm.call(
|
||||
"block_invoice",
|
||||
{
|
||||
hold_comment: dialog_data.hold_comment,
|
||||
release_date: dialog_data.release_date,
|
||||
},
|
||||
callback: (r) => me.frm.reload_doc(),
|
||||
});
|
||||
() => {
|
||||
me.frm.reload_doc();
|
||||
}
|
||||
);
|
||||
me.dialog.hide();
|
||||
});
|
||||
|
||||
@@ -341,10 +340,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
|
||||
set_release_date(data) {
|
||||
return frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
|
||||
args: data,
|
||||
callback: (r) => this.frm.reload_doc(),
|
||||
const me = this;
|
||||
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
|
||||
me.frm.reload_doc();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.on_hold",
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
"fieldname": "sb_14",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Hold Invoice"
|
||||
@@ -1694,7 +1695,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-12 23:54:21.263951",
|
||||
"modified": "2026-08-05 15:40:16.519774",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
|
||||
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.deferred_revenue import validate_service_stop_date
|
||||
@@ -309,6 +309,9 @@ class PurchaseInvoice(BuyingController):
|
||||
PurchaseTaxWithholding(self).on_validate()
|
||||
self.set_percentage_received()
|
||||
|
||||
if self.on_hold:
|
||||
self.validate_invoice_hold()
|
||||
|
||||
def set_percentage_received(self):
|
||||
total_billed_qty = 0.0
|
||||
total_received_qty = 0.0
|
||||
@@ -320,6 +323,13 @@ class PurchaseInvoice(BuyingController):
|
||||
if total_billed_qty and total_received_qty:
|
||||
self.per_received = total_received_qty / total_billed_qty * 100
|
||||
|
||||
def validate_invoice_hold(self):
|
||||
if self.is_return:
|
||||
frappe.throw(_("Return Purchase Invoice cannot be held."))
|
||||
|
||||
if self.docstatus < 1:
|
||||
frappe.throw(_("Purchase Invoice can be held after submitting."))
|
||||
|
||||
def validate_release_date(self):
|
||||
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
|
||||
frappe.throw(_("Release date must be in the future"))
|
||||
@@ -823,14 +833,38 @@ class PurchaseInvoice(BuyingController):
|
||||
def on_recurring(self, reference_doc, auto_repeat_doc):
|
||||
self.due_date = None
|
||||
|
||||
def block_invoice(self, hold_comment=None, release_date=None):
|
||||
self.db_set("on_hold", 1)
|
||||
self.db_set("hold_comment", cstr(hold_comment))
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
|
||||
self.check_permission("write")
|
||||
self.on_hold = 1
|
||||
self.release_date = release_date
|
||||
self.validate_block_invoice()
|
||||
|
||||
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def unblock_invoice(self):
|
||||
self.check_permission("write")
|
||||
self.db_set({"on_hold": 0, "release_date": None})
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
|
||||
self.check_permission("write")
|
||||
|
||||
if not self.on_hold:
|
||||
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
|
||||
|
||||
self.release_date = release_date
|
||||
self.validate_block_invoice()
|
||||
|
||||
self.db_set("release_date", release_date)
|
||||
|
||||
def unblock_invoice(self):
|
||||
self.db_set("on_hold", 0)
|
||||
self.db_set("release_date", None)
|
||||
def validate_block_invoice(self):
|
||||
self.validate_invoice_hold()
|
||||
if self.outstanding_amount <= 0:
|
||||
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
|
||||
|
||||
self.validate_release_date()
|
||||
|
||||
def set_status(self, update=False, status=None, update_modified=True):
|
||||
if self.is_new():
|
||||
@@ -928,24 +962,3 @@ def get_list_context(context=None):
|
||||
@erpnext.allow_regional
|
||||
def make_regional_gl_entries(gl_entries, doc):
|
||||
return gl_entries
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_release_date(name: str, release_date: str | None = None):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def unblock_invoice(name: str):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.unblock_invoice()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def block_invoice(name: str, release_date: str, hold_comment: str | None = None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.block_invoice(hold_comment, release_date)
|
||||
|
||||
@@ -521,8 +521,10 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
def get_stock_variance_account(self, item):
|
||||
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
|
||||
Variance; for all other items it keeps the existing behaviour (default expense account)."""
|
||||
"""Return the account for stock valuation difference.
|
||||
Standard Cost items use the Purchase Price Variance account. Other items use
|
||||
the default expense account, falling back to the item expense account for
|
||||
returns and the stock/asset received but not billed account for non-returns."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
@@ -530,7 +532,25 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
if item.item_code and get_valuation_method(item.item_code, self.doc.company) == "Standard Cost":
|
||||
return get_purchase_price_variance_account(item.item_code, self.doc.company)
|
||||
return self.doc.get_company_default("default_expense_account")
|
||||
|
||||
# 1. Primary choice: Company Default Expense / COGS Account
|
||||
default_expense = self.doc.get_company_default("default_expense_account", ignore_validation=True)
|
||||
if default_expense:
|
||||
return default_expense
|
||||
|
||||
# 2. If default_expense_account is NOT set (Unconfigured):
|
||||
# For returns, fall back to item.expense_account
|
||||
if self.doc.is_return and item.expense_account:
|
||||
return item.expense_account
|
||||
|
||||
# For non-returns, fall back to the clearing account used by Purchase Receipts.
|
||||
stock_asset_rbnb = (
|
||||
self.doc.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.doc.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
|
||||
return stock_asset_rbnb or item.expense_account
|
||||
|
||||
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
|
||||
doc = self.doc
|
||||
|
||||
@@ -278,14 +278,166 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
def test_purchase_invoice_explicit_block(self):
|
||||
pi = make_purchase_invoice()
|
||||
pi.block_invoice()
|
||||
release_date = add_days(nowdate(), 10)
|
||||
|
||||
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
|
||||
|
||||
self.assertEqual(pi.on_hold, 1)
|
||||
|
||||
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
|
||||
)
|
||||
self.assertEqual(on_hold, 1)
|
||||
self.assertEqual(hold_comment, "Waiting for the goods")
|
||||
self.assertEqual(getdate(saved_release_date), getdate(release_date))
|
||||
|
||||
pi.unblock_invoice()
|
||||
|
||||
self.assertEqual(pi.on_hold, 0)
|
||||
|
||||
on_hold, saved_release_date = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
|
||||
)
|
||||
self.assertEqual(on_hold, 0)
|
||||
self.assertIsNone(saved_release_date)
|
||||
|
||||
def test_purchase_invoice_cannot_be_held_before_submission(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
pi.on_hold = 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.save)
|
||||
|
||||
pi.on_hold = 0
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pi.block_invoice()
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
|
||||
|
||||
def test_return_purchase_invoice_cannot_be_held(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.on_hold = 1
|
||||
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
|
||||
|
||||
return_pi.on_hold = 0
|
||||
return_pi.save()
|
||||
return_pi.submit()
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
|
||||
|
||||
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
# a return has a negative outstanding amount, which must not be mistaken
|
||||
# for an invalid hold on a document that was never held
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.save()
|
||||
return_pi.submit()
|
||||
|
||||
self.assertEqual(return_pi.docstatus, 1)
|
||||
self.assertEqual(return_pi.on_hold, 0)
|
||||
self.assertLess(return_pi.outstanding_amount, 0)
|
||||
|
||||
def test_settled_purchase_invoice_cannot_be_held(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = nowdate()
|
||||
pe.save()
|
||||
pe.submit()
|
||||
|
||||
pi.reload()
|
||||
self.assertEqual(pi.outstanding_amount, 0)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
|
||||
|
||||
def test_release_date_of_held_invoice_must_be_in_future(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
|
||||
|
||||
def test_rejected_hold_does_not_partially_update_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
|
||||
|
||||
pi.reload()
|
||||
self.assertEqual(pi.on_hold, 0)
|
||||
self.assertIsNone(pi.release_date)
|
||||
|
||||
def test_change_release_date_of_held_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
|
||||
|
||||
new_release_date = add_days(nowdate(), 20)
|
||||
pi.change_release_date(new_release_date)
|
||||
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
|
||||
getdate(new_release_date),
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
|
||||
|
||||
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Invoice is not blocked",
|
||||
pi.change_release_date,
|
||||
add_days(nowdate(), 10),
|
||||
)
|
||||
|
||||
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
|
||||
|
||||
def test_hold_methods_are_whitelisted_document_methods(self):
|
||||
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
|
||||
|
||||
pi = frappe.new_doc("Purchase Invoice")
|
||||
|
||||
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
|
||||
# raises if the method is not whitelisted for client side calls
|
||||
pi.is_whitelisted(method)
|
||||
|
||||
self.assertFalse(
|
||||
hasattr(purchase_invoice_module, method),
|
||||
f"{method} should only be exposed as a document method",
|
||||
)
|
||||
|
||||
def test_hold_methods_require_write_permission(self):
|
||||
pi = make_purchase_invoice()
|
||||
user = "test_pi_hold_permission@example.com"
|
||||
|
||||
if not frappe.db.exists("User", user):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": user,
|
||||
"first_name": "Test PI Hold",
|
||||
"roles": [{"role": "Employee"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.set_user(user)
|
||||
try:
|
||||
self.assertRaises(frappe.PermissionError, pi.block_invoice)
|
||||
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
|
||||
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
|
||||
|
||||
def test_gl_entries_with_perpetual_inventory_against_pr(self):
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -1032,7 +1033,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.add_custom_button(__("Show Preview"), () => {
|
||||
frm.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Generating Preview"),
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let content = r.message;
|
||||
let opts = {
|
||||
title: "Preview",
|
||||
subtitle: "preview",
|
||||
content: content,
|
||||
print_settings: { orientation: "landscape" },
|
||||
columns: [],
|
||||
data: [],
|
||||
};
|
||||
frappe.render_grid(opts);
|
||||
}
|
||||
},
|
||||
// the server refuses only while the job is alive, so a dead one can be restarted here
|
||||
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
|
||||
frm.add_custom_button(__("Start Reposting"), () => {
|
||||
frm.events.start_repost(frm);
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus != 2) {
|
||||
frm.add_custom_button(__("Show Preview"), () => {
|
||||
frm.events.generate_preview(frm);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
generate_preview: function (frm) {
|
||||
frm.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Generating Preview"),
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let content = r.message;
|
||||
let opts = {
|
||||
title: "Preview",
|
||||
subtitle: "preview",
|
||||
content: content,
|
||||
print_settings: { orientation: "landscape" },
|
||||
columns: [],
|
||||
data: [],
|
||||
};
|
||||
frappe.render_grid(opts);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
start_repost: function (frm) {
|
||||
frm.call({
|
||||
method: "start_repost",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2023-07-04 13:07:32.923675",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
@@ -7,16 +8,24 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
"column_break_vpup",
|
||||
"delete_cancelled_entries",
|
||||
"column_break_vpup",
|
||||
"status",
|
||||
"section_break_metl",
|
||||
"vouchers",
|
||||
"amended_from"
|
||||
"error_section",
|
||||
"error_log",
|
||||
"miscellaneous_section",
|
||||
"amended_from",
|
||||
"column_break_hrah",
|
||||
"scheduled_job"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Company",
|
||||
"options": "Company"
|
||||
},
|
||||
@@ -48,12 +57,54 @@
|
||||
"fieldname": "delete_cancelled_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete Cancelled Ledger Entries"
|
||||
},
|
||||
{
|
||||
"fieldname": "error_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Error"
|
||||
},
|
||||
{
|
||||
"fieldname": "error_log",
|
||||
"fieldtype": "Code",
|
||||
"label": "Error Log",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "miscellaneous_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Miscellaneous"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hrah",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus >= 1;",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scheduled_job",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Scheduled Job",
|
||||
"no_copy": 1,
|
||||
"options": "RQ Job",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-03 17:30:37.012593",
|
||||
"modified": "2026-07-28 00:56:50.290314",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger",
|
||||
@@ -76,8 +127,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
|
||||
from frappe.utils.data import comma_and
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
# a batch has to finish well within the timeout of the job reposting it
|
||||
MAX_VOUCHERS_PER_REPOST = 50
|
||||
|
||||
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
|
||||
|
||||
|
||||
class RepostAccountingLedger(Document):
|
||||
@@ -26,6 +33,11 @@ class RepostAccountingLedger(Document):
|
||||
amended_from: DF.Link | None
|
||||
company: DF.Link | None
|
||||
delete_cancelled_entries: DF.Check
|
||||
error_log: DF.Code | None
|
||||
scheduled_job: DF.Link | None
|
||||
status: DF.Literal[
|
||||
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
|
||||
]
|
||||
vouchers: DF.Table[RepostAccountingLedgerItems]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -35,6 +47,11 @@ class RepostAccountingLedger(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_vouchers()
|
||||
self.validate_repost_preconditions()
|
||||
|
||||
def validate_repost_preconditions(self):
|
||||
"""The checks a repost queued days ago could have outlived, re-run before it touches
|
||||
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
|
||||
self.validate_for_closed_fiscal_year()
|
||||
self.validate_for_deferred_accounting()
|
||||
|
||||
@@ -71,8 +88,52 @@ class RepostAccountingLedger(Document):
|
||||
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
|
||||
|
||||
def validate_vouchers(self):
|
||||
if self.vouchers:
|
||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||
if not self.vouchers:
|
||||
frappe.throw(_("Add atleast one voucher to repost."))
|
||||
|
||||
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
|
||||
frappe.throw(
|
||||
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
|
||||
MAX_VOUCHERS_PER_REPOST
|
||||
)
|
||||
)
|
||||
|
||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||
|
||||
self.validate_no_duplicate_vouchers()
|
||||
self.validate_vouchers_are_submitted()
|
||||
|
||||
def validate_no_duplicate_vouchers(self):
|
||||
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
|
||||
|
||||
if len(vouchers) != len(set(vouchers)):
|
||||
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
|
||||
|
||||
def validate_vouchers_are_submitted(self):
|
||||
voucher_type_wise_map = {}
|
||||
for d in self.vouchers:
|
||||
voucher_type_wise_map.setdefault(d.voucher_type, [])
|
||||
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
|
||||
|
||||
non_submitted_vouchers = []
|
||||
for key in voucher_type_wise_map.keys():
|
||||
non_submitted_vouchers.extend(
|
||||
frappe.get_all(
|
||||
key,
|
||||
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
if non_submitted_vouchers:
|
||||
frappe.throw(
|
||||
_("The following vouchers are not submitted: {0}").format(
|
||||
comma_and(non_submitted_vouchers, add_quotes=True)
|
||||
)
|
||||
)
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def get_existing_ledger_entries(self):
|
||||
vouchers = [x.voucher_no for x in self.vouchers]
|
||||
@@ -137,80 +198,245 @@ class RepostAccountingLedger(Document):
|
||||
return rendered_page
|
||||
|
||||
def on_submit(self):
|
||||
if len(self.vouchers) > 5:
|
||||
job_name = "repost_accounting_ledger_" + self.name
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
|
||||
account_repost_doc=self.name,
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
self.start_repost()
|
||||
|
||||
def before_cancel(self):
|
||||
self._raise_error_if_reposting_in_progress()
|
||||
|
||||
def on_cancel(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def _raise_error_if_reposting_in_progress(self):
|
||||
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
|
||||
frappe.throw(_("Reposting is still in progress in background."))
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(self):
|
||||
if self.docstatus != 1:
|
||||
frappe.throw(_("Reposting can be started only for submitted document."))
|
||||
|
||||
# under a row lock, so two concurrent starts cannot both get past here
|
||||
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
|
||||
if status in ("Completed", "Cancelled"):
|
||||
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
|
||||
|
||||
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
|
||||
# died leaves the status behind and the document has to stay restartable
|
||||
self._raise_error_if_reposting_in_progress()
|
||||
|
||||
self.check_permission("write")
|
||||
|
||||
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
|
||||
if is_scheduler_inactive():
|
||||
frappe.msgprint(
|
||||
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
|
||||
alert=True,
|
||||
indicator="orange",
|
||||
)
|
||||
frappe.msgprint(_("Repost has started in the background"))
|
||||
else:
|
||||
start_repost(self.name)
|
||||
|
||||
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
|
||||
_enqueue_repost(self.name)
|
||||
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(account_repost_doc: str | None = None) -> None:
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
def _repost_job_id(repost_doc_name: str) -> str:
|
||||
"""Derived from the document, so a repost can only ever have one job."""
|
||||
return f"repost_accounting_ledger::{repost_doc_name}"
|
||||
|
||||
|
||||
def _enqueue_repost(repost_doc_name: str) -> None:
|
||||
"""Hand the repost to a background worker.
|
||||
|
||||
Tests run it in the foreground, inside their own transaction: documents edited after submit
|
||||
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
|
||||
ledger right after doing so.
|
||||
"""
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
|
||||
repost_doc_name=repost_doc_name,
|
||||
commit=not frappe.in_test,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
job_id=_repost_job_id(repost_doc_name),
|
||||
deduplicate=True,
|
||||
enqueue_after_commit=True,
|
||||
now=frappe.in_test,
|
||||
)
|
||||
|
||||
|
||||
def _lock_vouchers(vouchers) -> dict:
|
||||
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
|
||||
|
||||
Returns them keyed by voucher, so reposting does not load them again. These are file locks
|
||||
under the site directory: they serialise nothing across hosts that do not share it, and a
|
||||
worker killed outright leaves them behind until they expire.
|
||||
"""
|
||||
locked_docs = {}
|
||||
try:
|
||||
for x in vouchers:
|
||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||
doc.lock()
|
||||
locked_docs[(x.voucher_type, x.voucher_no)] = doc
|
||||
except Exception:
|
||||
for doc in locked_docs.values():
|
||||
doc.unlock()
|
||||
raise
|
||||
return locked_docs
|
||||
|
||||
|
||||
def repost(repost_doc_name: str, commit: bool = True):
|
||||
"""Repost every voucher of the document, one transaction at a time.
|
||||
|
||||
`commit` says whether this call owns the transaction. The background job does, and commits
|
||||
after every voucher so progress survives a crash; a caller inside its own passes `False`.
|
||||
"""
|
||||
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
|
||||
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
if account_repost_doc:
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
||||
repost_doc.check_permission("write")
|
||||
|
||||
if repost_doc.docstatus == 1:
|
||||
# Prevent repost on invoices with deferred accounting
|
||||
repost_doc.validate_for_deferred_accounting()
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
|
||||
locked_docs = {}
|
||||
|
||||
for x in repost_doc.vouchers:
|
||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||
try:
|
||||
repost_doc.validate_repost_preconditions()
|
||||
|
||||
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
|
||||
# and not reposted again
|
||||
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
|
||||
locked_docs = _lock_vouchers(pending)
|
||||
|
||||
repost_doc.db_set("status", "In Progress", commit=commit)
|
||||
|
||||
for position, x in enumerate(pending, start=1):
|
||||
frappe.publish_progress(
|
||||
position * 100 / len(pending),
|
||||
doctype=repost_doc.doctype,
|
||||
docname=repost_doc.name,
|
||||
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
|
||||
)
|
||||
|
||||
save_point = "reposting"
|
||||
frappe.db.savepoint(save_point=save_point)
|
||||
try:
|
||||
doc = locked_docs[(x.voucher_type, x.voucher_no)]
|
||||
|
||||
if doc.docstatus == 2:
|
||||
x.db_set({"status": "Skipped", "traceback": ""})
|
||||
continue
|
||||
|
||||
if repost_doc.delete_cancelled_entries:
|
||||
frappe.db.delete(
|
||||
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
||||
)
|
||||
frappe.db.delete(
|
||||
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
||||
)
|
||||
frappe.db.delete(
|
||||
"Advance Payment Ledger Entry",
|
||||
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
|
||||
)
|
||||
_delete_accounting_ledger_entries(doc.doctype, doc.name)
|
||||
_delete_adv_pl_entries(doc.doctype, doc.name)
|
||||
|
||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.docstatus = 2
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point=save_point)
|
||||
|
||||
doc.docstatus = 1
|
||||
if doc.doctype == "Sales Invoice":
|
||||
doc.force_set_against_income_account()
|
||||
else:
|
||||
doc.force_set_against_expense_account()
|
||||
doc.make_gl_entries()
|
||||
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
|
||||
else:
|
||||
x.db_set({"status": "Reposted", "traceback": ""})
|
||||
finally:
|
||||
if commit:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
elif doc.doctype == "Purchase Receipt":
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.docstatus = 2
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
except Exception:
|
||||
if commit:
|
||||
frappe.db.rollback()
|
||||
|
||||
doc.docstatus = 1
|
||||
doc.make_gl_entries(from_repost=True)
|
||||
_record_repost_failure(repost_doc, commit=commit)
|
||||
raise
|
||||
else:
|
||||
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
|
||||
finally:
|
||||
for doc in locked_docs.values():
|
||||
doc.unlock()
|
||||
if commit:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.make_gl_entries(1)
|
||||
doc.make_gl_entries()
|
||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
|
||||
doc.make_gl_entries(cancel=1)
|
||||
else:
|
||||
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||
doc.make_gl_entries()
|
||||
|
||||
def _derive_status(repost_doc) -> str:
|
||||
"""Vouchers are committed one by one, so the status follows what was actually handled."""
|
||||
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
|
||||
|
||||
if handled == len(repost_doc.vouchers):
|
||||
return "Completed"
|
||||
elif handled == 0:
|
||||
return "Failed"
|
||||
|
||||
return "Partially Reposted"
|
||||
|
||||
|
||||
def _record_repost_failure(repost_doc, commit=False) -> None:
|
||||
"""Persist the traceback of a run that could not finish, without discarding its progress."""
|
||||
# the traceback with frame locals goes to the Error Log, which is permissioned separately
|
||||
traceback = frappe.get_traceback()
|
||||
|
||||
frappe.log_error(
|
||||
title=_("Unable to Repost Accounting Ledger"),
|
||||
reference_doctype=repost_doc.doctype,
|
||||
reference_name=repost_doc.name,
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
|
||||
)
|
||||
|
||||
if commit:
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
|
||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
_repost_invoices(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype == "Purchase Receipt":
|
||||
_repost_purchase_receipt(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
|
||||
_repost_pe_je(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
|
||||
|
||||
|
||||
def _repost_invoices(invoice_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
invoice_doc.docstatus = 2
|
||||
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
invoice_doc.docstatus = 1
|
||||
if invoice_doc.doctype == "Sales Invoice":
|
||||
invoice_doc.force_set_against_income_account()
|
||||
else:
|
||||
invoice_doc.force_set_against_expense_account()
|
||||
invoice_doc.make_gl_entries()
|
||||
|
||||
|
||||
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
receipt_doc.docstatus = 2
|
||||
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
receipt_doc.docstatus = 1
|
||||
receipt_doc.make_gl_entries(from_repost=True)
|
||||
|
||||
|
||||
def _repost_pe_je(entry_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
entry_doc.make_gl_entries(cancel=1)
|
||||
entry_doc.make_gl_entries()
|
||||
|
||||
|
||||
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
|
||||
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
|
||||
if not delete_cancelled_entries:
|
||||
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
|
||||
repost_doc.make_gl_entries(cancel=1)
|
||||
else:
|
||||
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
|
||||
repost_doc.make_gl_entries()
|
||||
|
||||
|
||||
def get_allowed_types_from_settings(child_doc: bool = False):
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
frappe.listview_settings["Repost Accounting Ledger"] = {
|
||||
add_fields: ["status"],
|
||||
// drafts and cancelled documents are coloured by the framework before it gets here
|
||||
get_indicator: function (doc) {
|
||||
if (!doc.status) return;
|
||||
|
||||
const status_color = {
|
||||
Queued: "yellow",
|
||||
"In Progress": "blue",
|
||||
"Partially Reposted": "orange",
|
||||
Completed: "green",
|
||||
Failed: "red",
|
||||
};
|
||||
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
@@ -1,27 +1,42 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, nowdate, today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
|
||||
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
|
||||
_lock_vouchers,
|
||||
_record_repost_failure,
|
||||
_repost_allowed_hook_doctypes,
|
||||
_repost_job_id,
|
||||
_repost_vouchers,
|
||||
repost,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
|
||||
SIMULATED_FAILURE = "Simulated repost failure"
|
||||
|
||||
|
||||
class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
|
||||
update_repost_settings()
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = create_sales_invoice(
|
||||
def make_invoice(self, **kwargs):
|
||||
return create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def make_invoice_and_payment(self):
|
||||
si = self.make_invoice()
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
return si, pe
|
||||
|
||||
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.delete_cancelled_entries = delete_cancelled_entries
|
||||
for voucher in vouchers:
|
||||
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
|
||||
|
||||
ral.save()
|
||||
if submit:
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
return ral
|
||||
|
||||
@contextmanager
|
||||
def patched_repost(self, fail_for=()):
|
||||
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
|
||||
reposted = []
|
||||
|
||||
def repost_voucher(doc, delete_cancelled_entries):
|
||||
reposted.append(doc.name)
|
||||
if doc.doctype in fail_for:
|
||||
frappe.throw(SIMULATED_FAILURE)
|
||||
_repost_vouchers(doc, delete_cancelled_entries)
|
||||
|
||||
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
|
||||
yield reposted
|
||||
|
||||
def make_period_closing_voucher(self):
|
||||
fy = get_fiscal_year(today(), company="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": "Main - _TC",
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
return pcv.save().submit()
|
||||
|
||||
def get_gl_totals(self, voucher_no, is_cancelled=0):
|
||||
gl = qb.DocType("GL Entry")
|
||||
return (
|
||||
qb.from_(gl)
|
||||
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
|
||||
.run()
|
||||
)[0]
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = self.make_invoice()
|
||||
|
||||
preq = frappe.get_doc(
|
||||
make_payment_request(
|
||||
dt=si.doctype,
|
||||
@@ -64,53 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
|
||||
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
|
||||
|
||||
gl = qb.DocType("GL Entry")
|
||||
res = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.groupby(gl.voucher_no)
|
||||
.run()
|
||||
)
|
||||
|
||||
# Assert incorrect ledger balance
|
||||
self.assertNotEqual(res[0], (si.name, 100, 100))
|
||||
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
|
||||
|
||||
# Submit repost document
|
||||
ral.save().submit()
|
||||
|
||||
res = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.groupby(gl.voucher_no)
|
||||
.run()
|
||||
)
|
||||
|
||||
# Ledger should reflect correct amount post repost
|
||||
self.assertEqual(res[0], (si.name, 100, 100))
|
||||
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
|
||||
|
||||
def test_02_deferred_accounting_valiations(self):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si = self.make_invoice(do_not_submit=True)
|
||||
si.items[0].enable_deferred_revenue = True
|
||||
si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
|
||||
si.items[0].service_start_date = nowdate()
|
||||
si.items[0].service_end_date = add_days(nowdate(), 90)
|
||||
si.save().submit()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
self.assertRaises(frappe.ValidationError, ral.save)
|
||||
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_04_pcv_validation(self):
|
||||
@@ -118,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
fy = get_fiscal_year(today(), company="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": "Main - _TC",
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
pcv.save().submit()
|
||||
si = self.make_invoice()
|
||||
pcv = self.make_period_closing_voucher()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
self.assertRaises(frappe.ValidationError, ral.save)
|
||||
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||
|
||||
pcv.reload()
|
||||
pcv.cancel()
|
||||
pcv.delete()
|
||||
|
||||
def test_03_deletion_flag_and_preview_function(self):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
# with deletion flag set
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.delete_cancelled_entries = True
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||
ral.save().submit()
|
||||
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
|
||||
|
||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||
|
||||
def test_05_without_deletion_flag(self):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
# without deletion flag set
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test Company"
|
||||
ral.delete_cancelled_entries = False
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||
ral.save().submit()
|
||||
self.create_repost_doc([si, pe], submit=True)
|
||||
|
||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||
@@ -248,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
another_provisional_account,
|
||||
)
|
||||
|
||||
repost_doc = frappe.new_doc("Repost Accounting Ledger")
|
||||
repost_doc.company = "_Test Company"
|
||||
repost_doc.delete_cancelled_entries = True
|
||||
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
|
||||
repost_doc.save().submit()
|
||||
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
|
||||
|
||||
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
|
||||
expected_pr_gles_after_repost = [
|
||||
@@ -273,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
company.default_provisional_account = None
|
||||
company.save()
|
||||
|
||||
def test_07_voucher_validations(self):
|
||||
submitted_si = self.make_invoice()
|
||||
draft_si = self.make_invoice(do_not_submit=True)
|
||||
cancelled_si = self.make_invoice()
|
||||
cancelled_si.cancel()
|
||||
|
||||
for vouchers, exception, message in (
|
||||
([], frappe.ValidationError, "Add atleast one voucher"),
|
||||
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
|
||||
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
|
||||
# cancelled vouchers don't make it past link validation
|
||||
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
|
||||
):
|
||||
with self.subTest(vouchers=[x.name for x in vouchers]):
|
||||
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
|
||||
|
||||
self.create_repost_doc([submitted_si])
|
||||
|
||||
def test_08_voucher_count_limit(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
another_si = self.make_invoice()
|
||||
|
||||
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
|
||||
self.create_repost_doc([si, pe])
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot repost more than 2 vouchers",
|
||||
self.create_repost_doc,
|
||||
[si, pe, another_si],
|
||||
)
|
||||
|
||||
def test_09_status_lifecycle(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
self.assertEqual(ral.status, "")
|
||||
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertFalse(ral.error_log)
|
||||
for voucher in ral.vouchers:
|
||||
self.assertEqual(voucher.status, "Reposted")
|
||||
self.assertFalse(voucher.traceback)
|
||||
|
||||
ral.cancel()
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Cancelled")
|
||||
|
||||
discarded = self.create_repost_doc([si])
|
||||
discarded.discard()
|
||||
discarded.reload()
|
||||
self.assertEqual(discarded.status, "Cancelled")
|
||||
|
||||
def test_10_start_repost_guards(self):
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si])
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
|
||||
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
|
||||
)
|
||||
|
||||
# a document left behind by a worker that died mid-repost
|
||||
ral.db_set("status", "In Progress")
|
||||
|
||||
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "still in progress in background", ral.start_repost
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
|
||||
|
||||
# `cancel` flips docstatus in memory before running `before_cancel`
|
||||
ral.reload()
|
||||
|
||||
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
|
||||
# the job is gone, so `In Progress` must not keep the document stuck
|
||||
ral.start_repost()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
|
||||
def test_11_repost_job_is_tied_to_the_document(self):
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si], submit=True)
|
||||
ral.db_set("status", "Failed")
|
||||
|
||||
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
|
||||
ral.start_repost()
|
||||
|
||||
kwargs = enqueue.call_args.kwargs
|
||||
self.assertEqual(kwargs["repost_doc_name"], ral.name)
|
||||
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
|
||||
# a second start cannot queue a second job for the same document
|
||||
self.assertTrue(kwargs["deduplicate"])
|
||||
|
||||
def test_12_voucher_failures_are_isolated_and_retried(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
|
||||
|
||||
# the deletion flag drops the existing entries before reposting them
|
||||
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
|
||||
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Partially Reposted")
|
||||
|
||||
si_row, pe_row = ral.vouchers
|
||||
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
|
||||
self.assertFalse(si_row.traceback)
|
||||
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
|
||||
|
||||
# the failed voucher is rolled back to its savepoint, so its entries are back
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
|
||||
|
||||
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
|
||||
# alone entirely: they are not locked or loaded either
|
||||
with (
|
||||
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
|
||||
self.patched_repost() as retried,
|
||||
):
|
||||
ral.start_repost()
|
||||
|
||||
self.assertEqual(retried, [pe.name])
|
||||
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
for voucher in ral.vouchers:
|
||||
self.assertEqual(voucher.status, "Reposted")
|
||||
self.assertFalse(voucher.traceback)
|
||||
|
||||
def test_13_status_of_a_run_that_could_not_finish(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
|
||||
# the job dies after the loop committed the invoice, e.g. killed or timed out
|
||||
try:
|
||||
frappe.throw(SIMULATED_FAILURE)
|
||||
except frappe.ValidationError:
|
||||
_record_repost_failure(ral)
|
||||
|
||||
ral.reload()
|
||||
|
||||
# progress already committed must not be reported as a total failure
|
||||
self.assertEqual(ral.status, "Partially Reposted")
|
||||
self.assertIn(SIMULATED_FAILURE, ral.error_log)
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
|
||||
)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_14_period_closed_after_the_repost_was_started(self):
|
||||
gl = qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
|
||||
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si], submit=True)
|
||||
ral.db_set("status", "Failed")
|
||||
ral.vouchers[0].db_set("status", "Pending")
|
||||
|
||||
# the period is closed between the repost being started and the job running
|
||||
self.make_period_closing_voucher()
|
||||
|
||||
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Failed")
|
||||
self.assertIn("Closed fiscal year", ral.error_log)
|
||||
|
||||
# the ledger is left exactly as it was
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
|
||||
self.assertEqual(ral.vouchers[0].status, "Pending")
|
||||
|
||||
def test_15_failed_repost_skips_cancelled_voucher(self):
|
||||
si = self.make_invoice()
|
||||
|
||||
ral = self.create_repost_doc([si])
|
||||
with self.patched_repost(fail_for=["Sales Invoice"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Failed")
|
||||
|
||||
si.reload()
|
||||
si.cancel()
|
||||
|
||||
ral.start_repost()
|
||||
ral.reload()
|
||||
|
||||
# nothing was reposted, but there is nothing left to repost either
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertEqual(ral.vouchers[0].status, "Skipped")
|
||||
self.assertFalse(ral.vouchers[0].traceback)
|
||||
|
||||
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
|
||||
# a concurrent repost holding the lock on the second voucher
|
||||
locked_pe = frappe.get_doc(pe.doctype, pe.name)
|
||||
locked_pe.lock()
|
||||
try:
|
||||
self.assertRaises(frappe.DocumentLockedError, ral.submit)
|
||||
|
||||
# vouchers locked before the failure are released again
|
||||
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
|
||||
finally:
|
||||
locked_pe.unlock()
|
||||
|
||||
def test_17_journal_entry_repost(self):
|
||||
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
|
||||
je = frappe.get_doc("Journal Entry", je.name)
|
||||
|
||||
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||
|
||||
# without the deletion flag the 2 original entries are marked as cancelled,
|
||||
# along with the 2 reverse entries booked against them
|
||||
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
|
||||
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
|
||||
ral = self.create_repost_doc(
|
||||
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
|
||||
)
|
||||
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||
self.assertEqual(
|
||||
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
|
||||
cancelled_entries,
|
||||
)
|
||||
|
||||
def test_18_hook_allowed_doctype_repost(self):
|
||||
class VoucherWithCancelArg:
|
||||
doctype = "Test Repost Voucher"
|
||||
name = "TRV-00001"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def make_gl_entries(self, cancel=0):
|
||||
self.calls.append(cancel)
|
||||
|
||||
class VoucherWithoutCancelArg(VoucherWithCancelArg):
|
||||
def make_gl_entries(self):
|
||||
self.calls.append("repost")
|
||||
|
||||
# vouchers that can reverse their own entries are asked to do so first
|
||||
doc = VoucherWithCancelArg()
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||
self.assertEqual(doc.calls, [1, 0])
|
||||
|
||||
# nothing to reverse when the old entries are deleted
|
||||
doc = VoucherWithCancelArg()
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
|
||||
self.assertEqual(doc.calls, [0])
|
||||
|
||||
# the rest fall back to the generic reversal
|
||||
doc = VoucherWithoutCancelArg()
|
||||
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||
|
||||
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||
self.assertEqual(doc.calls, ["repost"])
|
||||
|
||||
|
||||
def update_repost_settings():
|
||||
allowed_types = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-07-04 14:14:01.243848",
|
||||
"doctype": "DocType",
|
||||
@@ -7,34 +8,70 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"voucher_type",
|
||||
"voucher_no"
|
||||
"column_break_ndex",
|
||||
"voucher_no",
|
||||
"reposting_status_section",
|
||||
"status",
|
||||
"traceback"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"columns": 5,
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher Type",
|
||||
"options": "DocType"
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ndex",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"columns": 5,
|
||||
"fieldname": "voucher_no",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher No",
|
||||
"options": "voucher_type"
|
||||
"options": "voucher_type",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reposting_status_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Reposting Status"
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"default": "Pending",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Pending\nReposted\nSkipped\nFailed",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "traceback",
|
||||
"fieldtype": "Code",
|
||||
"label": "Traceback",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:32.170897",
|
||||
"modified": "2026-07-29 02:41:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger Items",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
voucher_no: DF.DynamicLink | None
|
||||
voucher_type: DF.Link | None
|
||||
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
|
||||
traceback: DF.Code | None
|
||||
voucher_no: DF.DynamicLink
|
||||
voucher_type: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -587,7 +587,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
super.set_dynamic_labels();
|
||||
this.frm.events.hide_fields(this.frm);
|
||||
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
|
||||
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
|
||||
// metadata, so this always reflects the original (Customize Form) hidden value.
|
||||
const hidden_by_customization = cint(
|
||||
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
|
||||
);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock || hidden_by_customization);
|
||||
}
|
||||
|
||||
items_on_form_rendered() {
|
||||
|
||||
@@ -1168,6 +1168,7 @@ class SalesInvoice(SellingController):
|
||||
child_tables = {
|
||||
"items": ("income_account", "expense_account", "discount_account"),
|
||||
"taxes": ("account_head",),
|
||||
"payments": ("account",),
|
||||
}
|
||||
self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
|
||||
if self.needs_repost:
|
||||
|
||||
@@ -3207,12 +3207,15 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company 1")
|
||||
frappe.local.enable_perpetual_inventory["_Test Company 1"] = 1
|
||||
old_inventory_account = frappe.db.get_value("Company", "_Test Company 1", "default_inventory_account")
|
||||
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Company 1",
|
||||
"stock_received_but_not_billed",
|
||||
"Stock Received But Not Billed - _TC1",
|
||||
{
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC1",
|
||||
"default_inventory_account": "Stock In Hand - _TC1",
|
||||
},
|
||||
)
|
||||
|
||||
# companies are created with their Stores warehouse as Default Warehouse; clear it so the
|
||||
@@ -3255,6 +3258,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
# tear down
|
||||
frappe.local.enable_perpetual_inventory["_Test Company 1"] = old_perpetual_inventory
|
||||
frappe.db.set_value("Company", "_Test Company 1", "default_inventory_account", old_inventory_account)
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", old_negative_stock)
|
||||
|
||||
def test_sle_for_target_warehouse(self):
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -1066,7 +1067,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2016-05-08 23:49:38.842621",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -17,6 +18,7 @@
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "mode_of_payment",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
@@ -39,6 +41,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Account",
|
||||
@@ -47,6 +50,7 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fetch_from": "mode_of_payment.type",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Read Only",
|
||||
@@ -85,7 +89,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-16 20:46:34.592604",
|
||||
"modified": "2026-07-29 16:44:54.482826",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Payment",
|
||||
|
||||
@@ -161,7 +161,14 @@ class ShippingRule(Document):
|
||||
)
|
||||
shipping_charge["add_deduct_tax"] = "Add"
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
|
||||
shipping_charge_filters = shipping_charge.copy()
|
||||
if not self.cost_center:
|
||||
shipping_charge_filters["cost_center"] = (
|
||||
"in",
|
||||
(None, "", erpnext.get_default_cost_center(doc.company)),
|
||||
)
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
|
||||
if existing_shipping_charge:
|
||||
# take the last record found
|
||||
existing_shipping_charge[-1].tax_amount = shipping_amount
|
||||
|
||||
@@ -110,6 +110,32 @@ frappe.ui.form.on("Subscription", {
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Subscription Plan Detail", {
|
||||
plan: function (frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (!row.plan) return;
|
||||
const requested_plan = row.plan;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
|
||||
args: {
|
||||
plan: requested_plan,
|
||||
company: frm.doc.company,
|
||||
party_type: frm.doc.party_type,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
|
||||
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
|
||||
for (const [dimension, value] of Object.entries(r.message)) {
|
||||
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
|
||||
frm.set_value(dimension, value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
|
||||
// match the value frappe-charts shows in its hover tooltip.
|
||||
const HEATMAP_COLORS = {
|
||||
|
||||
@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
|
||||
class InvoiceCancelled(frappe.ValidationError):
|
||||
@@ -277,6 +278,9 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.status == STATUS_CANCELLED:
|
||||
return
|
||||
|
||||
self._set_current_invoice_dates()
|
||||
if self.is_trialling():
|
||||
self.status = STATUS_TRIALING
|
||||
@@ -672,7 +676,7 @@ class Subscription(Document):
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.next_billing_period_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
|
||||
):
|
||||
self.cancel_subscription()
|
||||
|
||||
@@ -981,6 +985,39 @@ def get_prorata_factor(
|
||||
return diff / plan_days
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_plan_dimensions(
|
||||
plan: str, company: str | None = None, party_type: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
|
||||
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
|
||||
|
||||
dimensions = {}
|
||||
for dimension in ["cost_center", *get_accounting_dimensions()]:
|
||||
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
|
||||
if value:
|
||||
dimensions[dimension] = value
|
||||
|
||||
return dimensions
|
||||
|
||||
|
||||
def get_item_dimension(
|
||||
item_code: str, dimension: str, company: str | None, party_type: str | None
|
||||
) -> str | None:
|
||||
if not company:
|
||||
return None
|
||||
|
||||
item_defaults = get_item_defaults(item_code, company)
|
||||
if dimension != "cost_center":
|
||||
return item_defaults.get(dimension)
|
||||
|
||||
selling = item_defaults.get("selling_cost_center")
|
||||
buying = item_defaults.get("buying_cost_center")
|
||||
if party_type == PARTY_SUPPLIER:
|
||||
return buying or selling
|
||||
return selling or buying
|
||||
|
||||
|
||||
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
|
||||
"""
|
||||
Task to updates the status of all `Subscription` apart from those that are cancelled
|
||||
|
||||
@@ -18,7 +18,12 @@ from frappe.utils.data import (
|
||||
)
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
|
||||
from erpnext.accounts.doctype.subscription.subscription import (
|
||||
Subscription,
|
||||
get_plan_dimensions,
|
||||
get_prorata_factor,
|
||||
process_all,
|
||||
)
|
||||
from erpnext.accounts.utils import update_subscription_on_invoice_update
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -774,6 +779,38 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
cancel_at_period_end=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
cancelation_date = getdate(subscription.cancelation_date)
|
||||
self.assertIsNotNone(cancelation_date)
|
||||
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
|
||||
|
||||
invoice_count = len(subscription.invoices)
|
||||
subscription.process()
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoice_count)
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
@@ -951,6 +988,47 @@ class TestSubscription(ERPNextTestSuite):
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
|
||||
|
||||
def test_plan_dimensions_resolve_from_plan_then_item(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
# Plan-level cost center takes precedence.
|
||||
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
|
||||
frappe.db.set_value(
|
||||
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
|
||||
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
|
||||
item = make_item(
|
||||
"_Test Sub Dimension Item",
|
||||
{
|
||||
"is_stock_item": 0,
|
||||
"item_defaults": [
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"selling_cost_center": "_Test Cost Center - _TC",
|
||||
"buying_cost_center": "_Test Cost Center 2 - _TC",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
|
||||
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
|
||||
"_Test Cost Center 2 - _TC",
|
||||
)
|
||||
|
||||
# Without a company the item fallback is skipped.
|
||||
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
|
||||
|
||||
|
||||
def make_full_credit_note(invoice_name):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
"modified": "2026-07-09 16:13:49.623613",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Party Account (Standard)",
|
||||
"name": "Party Account - Accounts",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -27,6 +27,6 @@
|
||||
"modified": "2026-07-10 11:26:57.841200",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry (Standard)",
|
||||
"name": "Payment Entry - Accounts",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -71,6 +71,6 @@
|
||||
"modified": "2026-07-20 15:56:46.025286",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice (Standard)",
|
||||
"name": "Purchase Invoice - Accounts",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -63,6 +63,6 @@
|
||||
"modified": "2026-07-20 15:32:43.080034",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice (Standard)",
|
||||
"name": "Sales Invoice - Accounts",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -19,6 +19,6 @@
|
||||
"modified": "2026-07-09 15:08:57.487184",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription (Standard)",
|
||||
"name": "Subscription - Accounts",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -865,9 +865,11 @@ def validate_account_party_type(self):
|
||||
|
||||
|
||||
def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
if not frappe.has_permission(doctype, "read"):
|
||||
return None
|
||||
|
||||
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
|
||||
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
|
||||
@@ -24,6 +24,11 @@ class TestAccountBalance(ERPNextTestSuite):
|
||||
"currency": "EUR",
|
||||
"balance": -100.0,
|
||||
},
|
||||
{
|
||||
"account": "Exchange Gain - _TC2",
|
||||
"currency": "EUR",
|
||||
"balance": 0.0,
|
||||
},
|
||||
{
|
||||
"account": "Income - _TC2",
|
||||
"currency": "EUR",
|
||||
|
||||
@@ -234,20 +234,36 @@ function create_payment_entries_from_payable_report(report) {
|
||||
return;
|
||||
}
|
||||
|
||||
// build per-(supplier, party_account) summary to match backend grouping key
|
||||
// validate against live state: only unpaid/partly-paid invoices with real outstanding are payable
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.bulk_payment.get_payable_invoices",
|
||||
args: { invoices: rows.map((r) => ({ voucher_no: r.voucher_no })) },
|
||||
callback: ({ message }) => {
|
||||
const { payable = [], excluded = [], currency } = message || {};
|
||||
if (!payable.length) {
|
||||
frappe.msgprint(__("None of the selected invoices are payable"));
|
||||
return;
|
||||
}
|
||||
show_create_payment_entries_dialog(report, payable, excluded, currency);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function show_create_payment_entries_dialog(report, payable, excluded, currency) {
|
||||
// group by (supplier, party_account) for the overview — matches the backend grouping key
|
||||
const supplierMap = {};
|
||||
for (const r of rows) {
|
||||
const key = `${r.party}||${r.party_account}`;
|
||||
for (const inv of payable) {
|
||||
const key = `${inv.supplier}||${inv.party_account}`;
|
||||
if (!supplierMap[key]) {
|
||||
supplierMap[key] = {
|
||||
supplier: r.party,
|
||||
party_account: r.party_account,
|
||||
supplier: inv.supplier,
|
||||
party_account: inv.party_account,
|
||||
count: 0,
|
||||
outstanding: 0,
|
||||
};
|
||||
}
|
||||
supplierMap[key].count += 1;
|
||||
supplierMap[key].outstanding += r.outstanding || 0;
|
||||
supplierMap[key].outstanding += inv.outstanding || 0;
|
||||
}
|
||||
|
||||
const overviewFields = [
|
||||
@@ -284,24 +300,36 @@ function create_payment_entries_from_payable_report(report) {
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [];
|
||||
if (excluded.length) {
|
||||
fields.push({ fieldtype: "HTML", fieldname: "excluded_note", options: excluded_note_html(excluded) });
|
||||
}
|
||||
fields.push({
|
||||
fieldname: "supplier_overview",
|
||||
fieldtype: "Table",
|
||||
label: __("Supplier Overview"),
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
fields: overviewFields,
|
||||
data: Object.values(supplierMap).map((d) => ({
|
||||
supplier: d.supplier,
|
||||
party_account: d.party_account,
|
||||
invoices: d.count,
|
||||
payable_amount: d.outstanding,
|
||||
})),
|
||||
});
|
||||
|
||||
const pe_count = Object.keys(supplierMap).length;
|
||||
const grand_total = Object.values(supplierMap).reduce((sum, d) => sum + d.outstanding, 0);
|
||||
fields.push({
|
||||
fieldtype: "HTML",
|
||||
fieldname: "summary_footer",
|
||||
options: summary_footer_html(pe_count, grand_total, currency),
|
||||
});
|
||||
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Create Payment Entries"),
|
||||
fields: [
|
||||
{
|
||||
fieldname: "supplier_overview",
|
||||
fieldtype: "Table",
|
||||
label: __("Supplier Overview"),
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
fields: overviewFields,
|
||||
data: Object.values(supplierMap).map((d) => ({
|
||||
supplier: d.supplier,
|
||||
party_account: d.party_account,
|
||||
invoices: d.count,
|
||||
payable_amount: d.outstanding,
|
||||
})),
|
||||
},
|
||||
],
|
||||
fields: fields,
|
||||
primary_action_label: __("Create"),
|
||||
secondary_action_label: __("Cancel"),
|
||||
secondary_action() {
|
||||
@@ -311,32 +339,15 @@ function create_payment_entries_from_payable_report(report) {
|
||||
primary_action() {
|
||||
dialog.hide();
|
||||
|
||||
const groupedKeys = new Set(
|
||||
Object.values(supplierMap)
|
||||
.filter((d) => d.count > 1)
|
||||
.map((d) => `${d.supplier}||${d.party_account}`)
|
||||
);
|
||||
|
||||
const grouped_invoices = [];
|
||||
const ungrouped_invoices = [];
|
||||
for (const r of rows) {
|
||||
const payload = {
|
||||
voucher_no: r.voucher_no,
|
||||
supplier: r.party,
|
||||
party_account: r.party_account,
|
||||
};
|
||||
(groupedKeys.has(`${r.party}||${r.party_account}`)
|
||||
? grouped_invoices
|
||||
: ungrouped_invoices
|
||||
).push(payload);
|
||||
}
|
||||
// backend re-derives supplier/party_account and grouping from live data
|
||||
const invoices = payable.map((inv) => ({ voucher_no: inv.voucher_no }));
|
||||
|
||||
const clearSelection = () => report.datatable.rowmanager.checkAll(false);
|
||||
|
||||
frappe
|
||||
.call({
|
||||
method: "erpnext.accounts.bulk_payment.create_payment_entries",
|
||||
args: { grouped_invoices, ungrouped_invoices },
|
||||
args: { invoices },
|
||||
})
|
||||
.then(clearSelection)
|
||||
.catch(clearSelection);
|
||||
@@ -345,6 +356,42 @@ function create_payment_entries_from_payable_report(report) {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
function summary_footer_html(pe_count, grand_total, currency) {
|
||||
return `<div style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--margin-sm);
|
||||
font-size: var(--text-sm);
|
||||
">
|
||||
<span class="text-muted">${__("Payment Entries are created as drafts for your review")}</span>
|
||||
<span>${__("{0} Payment Entries", [pe_count])} ·
|
||||
<strong>${format_currency(grand_total, currency)}</strong></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function excluded_note_html(excluded) {
|
||||
const counts = {};
|
||||
for (const e of excluded) {
|
||||
counts[e.reason] = (counts[e.reason] || 0) + 1;
|
||||
}
|
||||
const summary = Object.entries(counts)
|
||||
.map(([reason, n]) => `${n} ${reason}`)
|
||||
.join(", ");
|
||||
return `<div style="
|
||||
background-color: var(--bg-yellow);
|
||||
color: var(--text-on-yellow);
|
||||
font-size: var(--text-sm);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--padding-sm) var(--padding-md);
|
||||
margin-bottom: var(--margin-sm);
|
||||
">
|
||||
<span style="font-weight: var(--weight-medium);">${__("{0} invoice(s) excluded", [
|
||||
excluded.length,
|
||||
])}</span>: ${frappe.utils.escape_html(summary)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
erpnext.utils.add_dimensions("Accounts Payable", 10);
|
||||
|
||||
function get_party_type_options() {
|
||||
|
||||
@@ -107,6 +107,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_data(self):
|
||||
self.get_sales_invoices_or_customers_based_on_sales_person()
|
||||
self.get_invoices_based_on_sales_partner()
|
||||
|
||||
# Get invoice details like bill_no, due_date etc for all invoices
|
||||
self.get_invoice_details()
|
||||
@@ -242,6 +243,12 @@ class ReceivablePayableReport:
|
||||
):
|
||||
return
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
# a return is folded onto the invoice it settles, so match that invoice's
|
||||
# partner (like the sales_person filter above), not the return's own
|
||||
if ple.against_voucher_no not in self.sales_partner_invoices:
|
||||
return
|
||||
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
|
||||
else:
|
||||
@@ -459,7 +466,7 @@ class ReceivablePayableReport:
|
||||
"company": self.filters.company,
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["name", "due_date", "po_no"],
|
||||
fields=["name", "due_date", "po_no", "sales_partner"],
|
||||
)
|
||||
for d in si_list:
|
||||
self.invoice_details.setdefault(d.name, d)
|
||||
@@ -910,6 +917,22 @@ class ReceivablePayableReport:
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
return
|
||||
|
||||
self.sales_partner_invoices = set(
|
||||
frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={
|
||||
"sales_partner": self.filters.get("sales_partner"),
|
||||
"docstatus": 1,
|
||||
"company": self.filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
def prepare_conditions(self):
|
||||
self.qb_selection_filter = []
|
||||
self.or_filters = []
|
||||
@@ -1018,15 +1041,6 @@ class ReceivablePayableReport:
|
||||
|
||||
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
|
||||
)
|
||||
)
|
||||
|
||||
def exclude_employee_transaction(self):
|
||||
self.qb_selection_filter.append(self.ple.party_type != "Employee")
|
||||
|
||||
@@ -1115,9 +1129,6 @@ class ReceivablePayableReport:
|
||||
if self.account_type == "Receivable":
|
||||
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
fields.append("default_sales_partner")
|
||||
|
||||
self.party_details[party] = frappe.db.get_value(
|
||||
"Customer",
|
||||
party,
|
||||
@@ -1247,7 +1258,7 @@ class ReceivablePayableReport:
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
if self.filters.account_type == "Payable":
|
||||
self.add_column(
|
||||
|
||||
@@ -6,6 +6,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -1490,3 +1491,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIn(original_customer, parties)
|
||||
self.assertNotIn(second_customer, parties)
|
||||
self.assertEqual(allowed_invoice.customer, original_customer)
|
||||
|
||||
def test_receivable_filtered_by_sales_partner(self):
|
||||
frappe.set_user("Administrator")
|
||||
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
|
||||
for partner in (partner_a, partner_b):
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _si(sales_partner):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
|
||||
si.sales_partner = sales_partner
|
||||
return si.save().submit()
|
||||
|
||||
partner_a_si = _si(partner_a)
|
||||
partner_b_si = _si(partner_b)
|
||||
no_partner_si = _si(None)
|
||||
|
||||
# a return is folded onto the invoice it settles, so it nets against that
|
||||
# invoice's partner even when the return's own partner is cleared
|
||||
no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
|
||||
no_partner_return.sales_partner = None
|
||||
no_partner_return.items[0].qty = -1
|
||||
no_partner_return.update_outstanding_for_self = 0
|
||||
no_partner_return.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
def rows_for(partner):
|
||||
return {
|
||||
r.voucher_no: r
|
||||
for r in execute({**filters, "sales_partner": partner})[1]
|
||||
if r.get("voucher_no")
|
||||
}
|
||||
|
||||
rows_a = rows_for(partner_a)
|
||||
self.assertIn(partner_a_si.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
|
||||
self.assertNotIn(partner_b_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_return.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
|
||||
self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
|
||||
|
||||
rows_b = rows_for(partner_b)
|
||||
self.assertIn(partner_b_si.name, rows_b)
|
||||
self.assertNotIn(partner_a_si.name, rows_b)
|
||||
|
||||
@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
if row.sales_person:
|
||||
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
|
||||
if self.filters.sales_partner and row.get("sales_partner"):
|
||||
self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
|
||||
|
||||
def get_columns(self):
|
||||
self.columns = []
|
||||
@@ -193,7 +193,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
else:
|
||||
self.add_column(
|
||||
|
||||
@@ -191,3 +191,42 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
report = execute(filters)
|
||||
rpt_output = report[1]
|
||||
self.assertEqual(len(rpt_output), 0)
|
||||
|
||||
def test_03_summary_sales_partner_column(self):
|
||||
partner = "_Test AR Summary Sales Partner"
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=200,
|
||||
price_list_rate=200,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si.sales_partner = partner
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"customer": self.customer,
|
||||
"posting_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"sales_partner": partner,
|
||||
}
|
||||
|
||||
rpt_output = execute(filters)[1]
|
||||
self.assertEqual(len(rpt_output), 1)
|
||||
self.assertEqual(rpt_output[0].get("sales_partner"), partner)
|
||||
|
||||
@@ -553,8 +553,8 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc
|
||||
else:
|
||||
invoice_expense_map[d.parent][d.account_head] = flt(d.tax_amount)
|
||||
else:
|
||||
invoice_tax_map.setdefault(d.parent, frappe._dict()).setdefault(d.account_head, [])
|
||||
invoice_tax_map[d.parent][d.account_head] = flt(d.tax_amount)
|
||||
invoice_tax_map.setdefault(d.parent, frappe._dict()).setdefault(d.account_head, 0.0)
|
||||
invoice_tax_map[d.parent][d.account_head] += flt(d.tax_amount)
|
||||
|
||||
return invoice_expense_map, invoice_tax_map
|
||||
|
||||
|
||||
@@ -47,6 +47,41 @@ class TestPurchaseRegister(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
|
||||
|
||||
def test_add_and_deduct_rows_on_one_account_are_netted(self):
|
||||
"""An account head carrying both an Add and a Deduct row must report their net.
|
||||
|
||||
The tax query groups by (parent, account_head, add_deduct_tax), so such an account comes
|
||||
back as two rows. Only one of them survived into the report.
|
||||
"""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
|
||||
make_purchase_invoice as make_pi,
|
||||
)
|
||||
|
||||
company = "_Test Company"
|
||||
tax_account = "_Test Account VAT - _TC"
|
||||
|
||||
pi = make_pi(company=company, do_not_save=True)
|
||||
for add_deduct, amount in (("Add", 10), ("Deduct", 4)):
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": tax_account,
|
||||
"description": "VAT",
|
||||
"category": "Total",
|
||||
"add_deduct_tax": add_deduct,
|
||||
"tax_amount": amount,
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
filters = frappe._dict(company=company, from_date=add_months(today(), -1), to_date=today())
|
||||
row = next(r for r in execute(filters)[1] if r.get("voucher_no") == pi.name)
|
||||
|
||||
self.assertEqual(flt(row.get(frappe.scrub(tax_account))), 6.0)
|
||||
|
||||
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
|
||||
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
@@ -128,25 +128,47 @@ def get_pos_invoice_data(filters):
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
|
||||
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
|
||||
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
|
||||
t1 = (
|
||||
# t1: one row per invoice with the summed item base_total. warehouse and cost_center describe an
|
||||
# item line, not the invoice, and an invoice may carry several. warehouse then becomes an outer
|
||||
# grouping key below, so which line wins decides how rows are partitioned and what each row totals
|
||||
# -- not merely which label is shown. Max() over text is a sort, and MariaDB (case-folding) and
|
||||
# PostgreSQL (byte order) resolve it differently, so take both off one real line instead.
|
||||
# The representative is the first line the user entered: Min(idx) is an integer, so the pick is
|
||||
# free of collation and is meaningful, rather than turning on an unrelated hash-named row.
|
||||
grouped_items = (
|
||||
frappe.qb.from_(sii)
|
||||
.select(
|
||||
sii.parent,
|
||||
Sum(sii.amount).as_("base_total"),
|
||||
Max(sii.warehouse).as_("warehouse"),
|
||||
Max(sii.cost_center).as_("cost_center"),
|
||||
)
|
||||
.select(sii.parent, Sum(sii.amount).as_("base_total"), Min(sii.idx).as_("representative_idx"))
|
||||
.groupby(sii.parent)
|
||||
).as_("grouped_items")
|
||||
representative_item = frappe.qb.DocType("Sales Invoice Item").as_("representative_item")
|
||||
t1 = (
|
||||
frappe.qb.from_(grouped_items)
|
||||
.inner_join(representative_item)
|
||||
.on(
|
||||
(representative_item.parent == grouped_items.parent)
|
||||
& (representative_item.idx == grouped_items.representative_idx)
|
||||
)
|
||||
.select(
|
||||
grouped_items.parent,
|
||||
grouped_items.base_total,
|
||||
representative_item.warehouse,
|
||||
representative_item.cost_center,
|
||||
)
|
||||
)
|
||||
|
||||
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
|
||||
# t3: mode_of_payment per invoice, from one real payment line for the same reason
|
||||
grouped_payments = (
|
||||
frappe.qb.from_(sip).select(sip.parent, Min(sip.idx).as_("representative_idx")).groupby(sip.parent)
|
||||
).as_("grouped_payments")
|
||||
representative_payment = frappe.qb.DocType("Sales Invoice Payment").as_("representative_payment")
|
||||
t3 = (
|
||||
frappe.qb.from_(sip)
|
||||
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
|
||||
.groupby(sip.parent)
|
||||
frappe.qb.from_(grouped_payments)
|
||||
.inner_join(representative_payment)
|
||||
.on(
|
||||
(representative_payment.parent == grouped_payments.parent)
|
||||
& (representative_payment.idx == grouped_payments.representative_idx)
|
||||
)
|
||||
.select(grouped_payments.parent, representative_payment.mode_of_payment.as_("mode_of_payment"))
|
||||
)
|
||||
|
||||
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
|
||||
|
||||
@@ -54,6 +54,53 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
|
||||
self.assertIn("Credit Card", next(iter(mop.values())))
|
||||
self.assertNotIn("Cash", next(iter(mop.values())))
|
||||
|
||||
def test_pos_invoice_warehouse_and_cost_center_come_from_one_item(self):
|
||||
"""The reported warehouse and cost centre must belong to the same item line.
|
||||
|
||||
They describe a line, not the invoice, and an invoice can carry several. Aggregating each
|
||||
on its own can report a warehouse from one line beside a cost centre from another -- a pair
|
||||
that was never posted. The warehouse is also an outer grouping key, so the pick decides how
|
||||
rows are partitioned and what each one totals, not just what is displayed.
|
||||
"""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
low_warehouse = create_warehouse("_Test POS Summary AAA")
|
||||
high_warehouse = create_warehouse("_Test POS Summary ZZZ")
|
||||
second_item = make_item("_Test POS Summary Second Item", {"is_stock_item": 0}).name
|
||||
|
||||
si = create_sales_invoice_record()
|
||||
si.is_pos = 1
|
||||
# cross the two picks: the higher warehouse is on the line with the lower cost centre, so an
|
||||
# independently aggregated pair cannot belong to either line
|
||||
si.items[0].warehouse = high_warehouse
|
||||
si.items[0].cost_center = "Main - _TC"
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": second_item,
|
||||
"qty": 1,
|
||||
"rate": 5000,
|
||||
"income_account": "Sales - _TC",
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"warehouse": low_warehouse,
|
||||
"cost_center": "Sub - _TC",
|
||||
},
|
||||
)
|
||||
si.append("payments", {"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 15000})
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
posted = {(row.warehouse, row.cost_center) for row in si.items}
|
||||
self.assertGreater(len(posted), 1, "fixture must post more than one distinct pair")
|
||||
|
||||
rows = get_pos_invoice_data(get_filters())
|
||||
reported = [r for r in rows if r.get("warehouse") in {w for w, _ in posted}]
|
||||
self.assertTrue(reported)
|
||||
|
||||
for row in reported:
|
||||
self.assertIn((row["warehouse"], row["cost_center"]), posted)
|
||||
|
||||
def test_get_mode_of_payments_details(self):
|
||||
filters = get_filters()
|
||||
|
||||
|
||||
@@ -432,6 +432,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
d.flags.ignore_permissions = True
|
||||
d.cancel()
|
||||
d.delete()
|
||||
|
||||
|
||||
@@ -55,3 +55,16 @@ class DeferredAccountingService:
|
||||
|
||||
def _is_deferred(self, item) -> bool:
|
||||
return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"))
|
||||
|
||||
def clear_stale_deferred_fields(self) -> None:
|
||||
account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype)
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if self._is_deferred(item):
|
||||
continue
|
||||
|
||||
item.service_start_date = None
|
||||
item.service_end_date = None
|
||||
item.service_stop_date = None
|
||||
if account_field:
|
||||
item.set(account_field, None)
|
||||
|
||||
@@ -11,6 +11,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
|
||||
from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision
|
||||
|
||||
|
||||
def get_exchange_gain_loss_account(company: str, is_gain: bool) -> str | None:
|
||||
fieldname = "exchange_gain_account" if is_gain else "exchange_loss_account"
|
||||
return frappe.get_cached_value("Company", company, fieldname) or frappe.get_cached_value(
|
||||
"Company", company, "exchange_gain_loss_account"
|
||||
)
|
||||
|
||||
|
||||
def gain_loss_journal_already_booked(
|
||||
gain_loss_account: str,
|
||||
exc_gain_loss: float,
|
||||
@@ -163,9 +170,7 @@ def make_exchange_gain_loss_journal(
|
||||
|
||||
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
|
||||
|
||||
gain_loss_account = frappe.get_cached_value(
|
||||
"Company", doc.company, "exchange_gain_loss_account"
|
||||
)
|
||||
gain_loss_account = get_exchange_gain_loss_account(doc.company, reverse_dr_or_cr == "credit")
|
||||
je = create_gain_loss_journal(
|
||||
doc.company,
|
||||
args.get("difference_posting_date") if args else doc.posting_date,
|
||||
@@ -195,7 +200,7 @@ def make_exchange_gain_loss_journal(
|
||||
|
||||
def is_payable_account(reference_doctype: str, account: str) -> bool:
|
||||
if reference_doctype == "Purchase Invoice" or (
|
||||
reference_doctype == "Journal Entry"
|
||||
reference_doctype in ("Journal Entry", "Payment Entry")
|
||||
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -2379,13 +2379,16 @@ class QueryPaymentLedger:
|
||||
)
|
||||
|
||||
# build query for voucher amount
|
||||
query_voucher_amount = (
|
||||
# account is grouped, not aggregated: it is a join key against the outstanding CTE below, and
|
||||
# it fixes the currency the amounts are summed in. The two CTEs aggregate over different row
|
||||
# sets, so two Max() picks could disagree and the join would silently miss, leaving the
|
||||
# outstanding NULL. posting_date/due_date are dates, so Max() there cannot depend on
|
||||
# collation. cost_center and remarks are free text that genuinely varies per row, so they
|
||||
# come off one real row instead -- see representative below.
|
||||
grouped_voucher_amount = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
|
||||
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
|
||||
# every non-aggregated column to be grouped or aggregated)
|
||||
Max(ple.account).as_("account"),
|
||||
ple.account,
|
||||
ple.voucher_type,
|
||||
ple.voucher_no,
|
||||
ple.party_type,
|
||||
@@ -2393,25 +2396,47 @@ class QueryPaymentLedger:
|
||||
Max(ple.posting_date).as_("posting_date"),
|
||||
Max(ple.due_date).as_("due_date"),
|
||||
Max(ple.account_currency).as_("currency"),
|
||||
Max(ple.cost_center).as_("cost_center"),
|
||||
Sum(ple.amount).as_("amount"),
|
||||
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
|
||||
Max(ple.remarks).as_("remarks"),
|
||||
Min(ple.name).as_("representative"),
|
||||
)
|
||||
.where(ple.delinked == 0)
|
||||
.where(Criterion.all(filter_on_voucher_no))
|
||||
.where(Criterion.all(self.common_filter))
|
||||
.where(Criterion.all(self.dimensions_filter))
|
||||
.where(Criterion.all(self.voucher_posting_date))
|
||||
.groupby(ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
|
||||
.groupby(ple.account, ple.voucher_type, ple.voucher_no, ple.party_type, ple.party)
|
||||
).as_("grouped")
|
||||
|
||||
# Payment Ledger Entry has no autoname rule, so frappe names it by hash -- lower-case, which
|
||||
# keeps Min(name) free of the collation divergence that picking Max() over free text has.
|
||||
representative_ple = qb.DocType("Payment Ledger Entry").as_("representative_ple")
|
||||
query_voucher_amount = (
|
||||
qb.from_(grouped_voucher_amount)
|
||||
.inner_join(representative_ple)
|
||||
.on(representative_ple.name == grouped_voucher_amount.representative)
|
||||
.select(
|
||||
grouped_voucher_amount.account,
|
||||
grouped_voucher_amount.voucher_type,
|
||||
grouped_voucher_amount.voucher_no,
|
||||
grouped_voucher_amount.party_type,
|
||||
grouped_voucher_amount.party,
|
||||
grouped_voucher_amount.posting_date,
|
||||
grouped_voucher_amount.due_date,
|
||||
grouped_voucher_amount.currency,
|
||||
grouped_voucher_amount.amount,
|
||||
grouped_voucher_amount.amount_in_account_currency,
|
||||
representative_ple.cost_center.as_("cost_center"),
|
||||
representative_ple.remarks.as_("remarks"),
|
||||
)
|
||||
)
|
||||
|
||||
# build query for voucher outstanding
|
||||
query_voucher_outstanding = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
# Max() on columns constant per group keeps this valid on postgres (see above)
|
||||
Max(ple.account).as_("account"),
|
||||
# grouped, not aggregated: this is the other side of the join key -- see above
|
||||
ple.account,
|
||||
ple.against_voucher_type.as_("voucher_type"),
|
||||
ple.against_voucher_no.as_("voucher_no"),
|
||||
ple.party_type,
|
||||
@@ -2425,7 +2450,7 @@ class QueryPaymentLedger:
|
||||
.where(ple.delinked == 0)
|
||||
.where(Criterion.all(filter_on_against_voucher_no))
|
||||
.where(Criterion.all(self.common_filter))
|
||||
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
|
||||
.groupby(ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
|
||||
)
|
||||
|
||||
# build CTE for combining voucher amount and outstanding
|
||||
|
||||
@@ -1203,7 +1203,7 @@ def get_values_from_purchase_doc(
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
"purchase_date": purchase_doc.get("posting_date"),
|
||||
"net_purchase_amount": flt(first_item.base_net_amount),
|
||||
"net_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
"asset_location": first_item.get("asset_location"),
|
||||
|
||||
@@ -539,11 +539,13 @@ def get_target_asset_details(asset: str | None = None, company: str | None = Non
|
||||
@frappe.whitelist()
|
||||
@erpnext.normalize_ctx_input(ItemDetailsCtx)
|
||||
def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
|
||||
frappe.has_permission("Stock Ledger Entry", throw=True)
|
||||
out = frappe._dict()
|
||||
|
||||
item = frappe._dict()
|
||||
if ctx.item_code:
|
||||
item = frappe.get_cached_doc("Item", ctx.item_code)
|
||||
item.check_permission()
|
||||
|
||||
out.item_name = item.item_name
|
||||
out.batch_no = None
|
||||
@@ -553,6 +555,8 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
|
||||
out.stock_uom = item.stock_uom
|
||||
|
||||
out.warehouse = get_item_warehouse_(ctx, item, overwrite_warehouse=True) if item else None
|
||||
if out.warehouse:
|
||||
frappe.has_permission("Warehouse", doc=out.warehouse, throw=True)
|
||||
|
||||
# Cost Center
|
||||
item_defaults = get_item_defaults(item.name, ctx.company)
|
||||
@@ -589,6 +593,9 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
|
||||
def get_warehouse_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
out = frappe._dict()
|
||||
if ctx.warehouse and ctx.item_code:
|
||||
frappe.has_permission("Item", doc=ctx.item_code, throw=True)
|
||||
frappe.has_permission("Warehouse", doc=ctx.warehouse, throw=True)
|
||||
frappe.has_permission("Stock Ledger Entry", throw=True)
|
||||
out = frappe._dict(
|
||||
{
|
||||
"actual_qty": get_previous_sle(ctx).get("qty_after_transaction") or 0,
|
||||
|
||||
@@ -310,12 +310,13 @@ class PurchaseOrder(BuyingController):
|
||||
itemwise_qty.setdefault(d.item_code, 0)
|
||||
itemwise_qty[d.item_code] += flt(d.stock_qty)
|
||||
|
||||
precision = self.items[0].precision("stock_qty")
|
||||
for item_code, qty in itemwise_qty.items():
|
||||
if flt(qty) < flt(itemwise_min_order_qty.get(item_code)):
|
||||
if flt(qty, precision) < flt(itemwise_min_order_qty.get(item_code), precision):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Item {0}: Ordered qty {1} cannot be less than minimum order qty {2} (defined in Item)."
|
||||
).format(item_code, qty, itemwise_min_order_qty.get(item_code))
|
||||
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
|
||||
)
|
||||
|
||||
def get_schedule_dates(self):
|
||||
|
||||
@@ -216,6 +216,21 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po2.items[0].qty = 110
|
||||
self.assertRaises(OverAllowanceError, po2.submit)
|
||||
|
||||
# Stock over-delivery role must not bypass over-ordering against Material Request.
|
||||
with self.change_settings(
|
||||
"Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"}
|
||||
):
|
||||
test_user = frappe.get_doc("User", "test@example.com")
|
||||
test_user.add_roles("Stock Manager")
|
||||
|
||||
mr3 = make_material_request(qty=100)
|
||||
po3 = make_purchase_order(mr3.name)
|
||||
po3.supplier = "_Test Supplier"
|
||||
po3.items[0].qty = 110
|
||||
with self.set_user("test@example.com"):
|
||||
po3.flags.ignore_permissions = True
|
||||
self.assertRaises(OverAllowanceError, po3.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)
|
||||
@@ -692,6 +707,42 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po = create_purchase_order(qty=3.4, do_not_save=True)
|
||||
self.assertRaises(UOMMustBeIntegerError, po.insert)
|
||||
|
||||
def test_min_order_qty_with_uom_conversion_dust(self):
|
||||
item_doc = make_item(properties={"min_order_qty": 2000, "stock_uom": "Kg"})
|
||||
item_doc.append("uoms", {"uom": "Litre", "conversion_factor": 0.6})
|
||||
item_doc.save()
|
||||
item = item_doc.name
|
||||
|
||||
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
|
||||
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
|
||||
po.items[0].uom = "Litre"
|
||||
po.items[0].conversion_factor = 0.6
|
||||
po.insert()
|
||||
|
||||
below_minimum = create_purchase_order(item_code=item, qty=3000, do_not_save=1)
|
||||
below_minimum.items[0].uom = "Litre"
|
||||
below_minimum.items[0].conversion_factor = 0.6
|
||||
self.assertRaises(frappe.ValidationError, below_minimum.insert)
|
||||
|
||||
def test_uom_integer_check_tolerates_conversion_dust(self):
|
||||
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
|
||||
|
||||
item_doc = make_item(properties={"stock_uom": "Nos"})
|
||||
item_doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.6})
|
||||
item_doc.save()
|
||||
item = item_doc.name
|
||||
|
||||
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
|
||||
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
|
||||
po.items[0].uom = "Kg"
|
||||
po.items[0].conversion_factor = 0.6
|
||||
po.insert()
|
||||
|
||||
fractional = create_purchase_order(item_code=item, qty=3333.9, do_not_save=1)
|
||||
fractional.items[0].uom = "Kg"
|
||||
fractional.items[0].conversion_factor = 0.6
|
||||
self.assertRaises(UOMMustBeIntegerError, fractional.insert)
|
||||
|
||||
def test_ordered_qty_for_closing_po(self):
|
||||
bin = frappe.get_all(
|
||||
"Bin",
|
||||
@@ -1044,6 +1095,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
|
||||
compare_payment_schedules(self, po, pi)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1})
|
||||
def test_internal_transfer_flow(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import (
|
||||
@@ -1055,9 +1108,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
)
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
|
||||
|
||||
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
supplier = "_Test Internal Supplier 2"
|
||||
|
||||
@@ -1496,6 +1546,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
self.assertEqual(pi_2.status, "Paid")
|
||||
self.assertEqual(po.status, "Completed")
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0})
|
||||
def test_purchase_order_over_billing_missing_item(self):
|
||||
item1 = make_item(
|
||||
"_Test Item for Overbilling",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"label": "UOM Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"print_width": "100px",
|
||||
"reqd": 1,
|
||||
@@ -953,7 +954,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-29 12:00:00.000000",
|
||||
"modified": "2026-08-07 18:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"label": "Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -207,7 +208,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:26.235916",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Receipt Item Supplied",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -274,7 +275,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-15 00:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Item",
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -614,7 +615,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -47,6 +47,6 @@
|
||||
"modified": "2026-07-20 15:54:26.047600",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order (Standard)",
|
||||
"name": "Purchase Order - Buying",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -19,6 +19,6 @@
|
||||
"modified": "2026-07-03 17:18:03.006829",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation (Standard)",
|
||||
"name": "Request for Quotation - Buying",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -15,6 +15,6 @@
|
||||
"modified": "2026-07-03 17:14:32.891939",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation (Standard)",
|
||||
"name": "Supplier Quotation - Buying",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
@@ -51,7 +51,6 @@ def get_data(filters):
|
||||
mr_item.item_code.as_("item_code"),
|
||||
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
|
||||
Max(Coalesce(mr_item.uom, "")).as_("uom"),
|
||||
Max(Coalesce(mr_item.stock_uom, "")).as_("stock_uom"),
|
||||
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
@@ -60,8 +59,6 @@ def get_data(filters):
|
||||
),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0))).as_("qty_to_order"),
|
||||
Max(mr_item.item_name).as_("item_name"),
|
||||
Max(mr_item.description).as_("description"),
|
||||
Max(mr.company).as_("company"),
|
||||
)
|
||||
.where(
|
||||
@@ -75,8 +72,34 @@ def get_data(filters):
|
||||
query = get_conditions(filters, query, mr, mr_item) # add conditional conditions
|
||||
|
||||
query = query.groupby(mr.name, mr_item.item_code).orderby(Max(mr.transaction_date), Max(mr.schedule_date))
|
||||
data = query.run(as_dict=True)
|
||||
return data
|
||||
rows = query.run(as_dict=True)
|
||||
apply_representative_lines(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def apply_representative_lines(rows):
|
||||
"""Fill item_name/description/uom from one real Material Request Item line per group.
|
||||
|
||||
All three are editable per line, so a request listing the same item twice holds several values
|
||||
per group. Aggregating them sorts text, and MariaDB folds case while PostgreSQL orders by byte
|
||||
value, so the engines pick differently. Take the first line by idx.
|
||||
"""
|
||||
material_requests = list({row.material_request for row in rows})
|
||||
representative = {}
|
||||
if material_requests:
|
||||
for line in frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"parent": ("in", material_requests), "docstatus": 1},
|
||||
fields=["parent", "item_code", "item_name", "description", "uom"],
|
||||
order_by="idx",
|
||||
):
|
||||
representative.setdefault((line.parent, line.item_code), line)
|
||||
|
||||
for row in rows:
|
||||
line = representative.get((row.material_request, row.item_code))
|
||||
row.item_name = line.item_name if line else None
|
||||
row.description = line.description if line else None
|
||||
row.uom = line.uom if line else ""
|
||||
|
||||
|
||||
def get_conditions(filters, query, mr, mr_item):
|
||||
|
||||
@@ -253,7 +253,9 @@ class AccountsController(TransactionBase):
|
||||
else:
|
||||
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService
|
||||
|
||||
DeferredAccountingService(self).validate_start_and_end_date()
|
||||
deferred_service = DeferredAccountingService(self)
|
||||
deferred_service.clear_stale_deferred_fields()
|
||||
deferred_service.validate_start_and_end_date()
|
||||
|
||||
from erpnext.accounts.services.internal_transfer import InternalTransferService
|
||||
|
||||
@@ -1054,9 +1056,16 @@ class AccountsController(TransactionBase):
|
||||
party_account = self.credit_to
|
||||
dr_or_cr = "debit_in_account_currency"
|
||||
|
||||
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
|
||||
|
||||
lst = []
|
||||
for d in self.get("advances"):
|
||||
if flt(d.allocated_amount) > 0:
|
||||
is_gain = (
|
||||
flt(d.get("exchange_gain_loss")) > 0
|
||||
if party_type == "Customer"
|
||||
else flt(d.get("exchange_gain_loss")) < 0
|
||||
)
|
||||
args = frappe._dict(
|
||||
{
|
||||
"voucher_type": d.reference_type,
|
||||
@@ -1083,9 +1092,7 @@ class AccountsController(TransactionBase):
|
||||
else self.grand_total
|
||||
),
|
||||
"outstanding_amount": self.outstanding_amount,
|
||||
"difference_account": frappe.get_cached_value(
|
||||
"Company", self.company, "exchange_gain_loss_account"
|
||||
),
|
||||
"difference_account": get_exchange_gain_loss_account(self.company, is_gain),
|
||||
"exchange_gain_loss": flt(d.get("exchange_gain_loss")),
|
||||
"difference_posting_date": d.get("difference_posting_date"),
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ class BuyingController(SubcontractingController):
|
||||
self.precision("item_tax_amount", item),
|
||||
)
|
||||
|
||||
self.round_floats_in(item)
|
||||
self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
|
||||
if flt(item.conversion_factor) == 0.0:
|
||||
item.conversion_factor = (
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
|
||||
@@ -346,6 +346,7 @@ def create_variant(item: str, args: dict | str, use_template_image: bool = False
|
||||
def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_image: bool = False):
|
||||
use_template_image = frappe.parse_json(use_template_image)
|
||||
# There can be innumerable attribute combinations, enqueue
|
||||
frappe.has_permission("Item", ptype="create", throw=True)
|
||||
variants = frappe.parse_json(args)
|
||||
variants = {key: values for key, values in variants.items() if values}
|
||||
if not variants:
|
||||
|
||||
@@ -73,14 +73,17 @@ def employee_query(
|
||||
.where(Criterion.any(search_conditions))
|
||||
.orderby(
|
||||
Case()
|
||||
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(txt_no_percent, Employee.employee_name) > 0,
|
||||
Locate(txt_no_percent, Employee.employee_name),
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
@@ -136,17 +139,28 @@ def lead_query(
|
||||
query.where(Lead.docstatus < 2)
|
||||
.where(Lead.status.isnull() | (Lead.status != "Converted"))
|
||||
.where(Criterion.any(search_conditions))
|
||||
.orderby(
|
||||
Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.company_name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(Lead.company_name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(Lead.idx, order=Order.desc)
|
||||
@@ -387,7 +401,12 @@ def bom(
|
||||
.where(BOM.is_active == 1)
|
||||
.where(BOM[searchfield].like(f"%{txt}%"))
|
||||
.orderby(
|
||||
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
|
||||
Case()
|
||||
.when(
|
||||
Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0,
|
||||
Locate(Lower(txt_no_percent), Lower(BOM.name)),
|
||||
)
|
||||
.else_(99999)
|
||||
)
|
||||
.orderby(BOM.idx, order=Order.desc)
|
||||
.orderby(BOM.name)
|
||||
|
||||
@@ -160,10 +160,28 @@ def validate_returned_items(doc):
|
||||
):
|
||||
frappe.throw(_("Warehouse is mandatory"))
|
||||
|
||||
items_returned = True
|
||||
if doc.doctype in (
|
||||
"Purchase Invoice",
|
||||
"Purchase Receipt",
|
||||
"Subcontracting Receipt",
|
||||
"Sales Invoice",
|
||||
"Delivery Note",
|
||||
"POS Invoice",
|
||||
):
|
||||
if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
|
||||
elif d.item_name:
|
||||
items_returned = True
|
||||
if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
|
||||
# No item_code here means no linked Item, so there's no accepted/rejected
|
||||
# split to speak of - received_qty isn't a meaningful independent signal.
|
||||
# Only a negative qty (i.e. a real negative billing amount) counts.
|
||||
if flt(d.qty) < 0:
|
||||
items_returned = True
|
||||
else:
|
||||
items_returned = True
|
||||
|
||||
if not items_returned:
|
||||
frappe.throw(_("At least one item should be entered with negative quantity in return document"))
|
||||
|
||||
@@ -499,11 +499,12 @@ class StatusUpdater(Document):
|
||||
else (0, {}, None, None)
|
||||
)
|
||||
|
||||
role_allowed_to_over_deliver_receive = frappe.get_single_value(
|
||||
"Stock Settings", "role_allowed_to_over_deliver_receive"
|
||||
)
|
||||
role_allowed_to_over_bill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
|
||||
role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill
|
||||
role = None
|
||||
if qty_or_amount == "qty":
|
||||
if args.get("overflow_type") in ("delivery", "receipt"):
|
||||
role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive")
|
||||
else:
|
||||
role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
|
||||
|
||||
overflow_percent = (
|
||||
(item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]]
|
||||
|
||||
@@ -225,7 +225,12 @@ class calculate_taxes_and_totals:
|
||||
if self.doc.get("is_consolidated") or self.discount_amount_applied:
|
||||
return
|
||||
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
|
||||
do_not_round_fields = [
|
||||
"valuation_rate",
|
||||
"incoming_rate",
|
||||
"sales_incoming_rate",
|
||||
"conversion_factor",
|
||||
]
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
self.calculate_item_rate(item)
|
||||
|
||||
@@ -29,6 +29,27 @@ class TestQueries(ERPNextTestSuite):
|
||||
self.assertGreaterEqual(len(query(txt="_Test Lead")), 4)
|
||||
self.assertEqual(len(query(txt="_Test Lead 4")), 1)
|
||||
|
||||
def test_lead_query_ranking_is_case_insensitive(self):
|
||||
"""A match at the start must rank first whatever its case.
|
||||
|
||||
The filter uses .like(), which frappe renders as ILIKE on PostgreSQL, so both leads match.
|
||||
Ranking used a bare Locate(), which becomes case-sensitive strpos() there: the upper-cased
|
||||
lead scores no match, falls back to 99999 and sorts last, while MariaDB's case-insensitive
|
||||
LOCATE ranks it first. Same query, different order -- and a different page when page_len is
|
||||
small enough to cut between them.
|
||||
"""
|
||||
early, late = "ZZABCD Ranking Lead", "Ranking Lead zzabcd"
|
||||
for lead_name in (early, late):
|
||||
if not frappe.db.exists("Lead", {"lead_name": lead_name}):
|
||||
frappe.get_doc({"doctype": "Lead", "lead_name": lead_name}).insert()
|
||||
|
||||
query = add_default_params(queries.lead_query, "Lead")
|
||||
names = [row[1] for row in query(txt="zzabcd")]
|
||||
|
||||
self.assertIn(early, names)
|
||||
self.assertIn(late, names)
|
||||
self.assertLess(names.index(early), names.index(late))
|
||||
|
||||
def test_item_query(self):
|
||||
query = add_default_params(queries.item_query, "Item")
|
||||
|
||||
|
||||
@@ -37,3 +37,76 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(return_dn.is_return, 1)
|
||||
self.assertEqual(return_dn.items[0].qty, -5)
|
||||
|
||||
def test_purchase_invoice_zero_qty_return_is_rejected(self):
|
||||
# A return with every item at qty 0 moves no stock and no value, so it must be
|
||||
# rejected the same way a return with no items at all would be.
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
pi = make_purchase_invoice(qty=10)
|
||||
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=0,
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_pi.save)
|
||||
|
||||
def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
|
||||
# Item Code is not mandatory on Purchase Invoice Item - a row can have only an
|
||||
# item_name (e.g. a free-text/non-stock line). Such rows fall through to the
|
||||
# item_name-only branch, which must also reject an all-zero-qty return instead
|
||||
# of unconditionally treating the row as returned.
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
|
||||
pi.items[0].item_code = ""
|
||||
pi.save()
|
||||
pi.submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
item_name="_Test Item",
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=0,
|
||||
do_not_save=True,
|
||||
)
|
||||
return_pi.items[0].item_code = ""
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_pi.save)
|
||||
|
||||
def test_delivery_note_zero_qty_return_is_rejected(self):
|
||||
# A return with every item at qty 0 moves no stock and no value, so it must be
|
||||
# rejected the same way a return with no items at all would be.
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
|
||||
|
||||
dn = create_delivery_note(qty=5)
|
||||
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_dn.insert)
|
||||
|
||||
def test_sales_invoice_zero_qty_return_is_rejected(self):
|
||||
# Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
|
||||
# every row must be rejected, not silently accepted as a no-op credit note.
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
si = create_sales_invoice(qty=10)
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
return_si = make_return_doc(si.doctype, si.name)
|
||||
return_si.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_si.save)
|
||||
|
||||
@@ -376,6 +376,41 @@ def get_period_month_ranges(period, fiscal_year):
|
||||
return period_month_ranges
|
||||
|
||||
|
||||
def quotation_party_name_expr():
|
||||
"""Resolve a Quotation's party label from its dynamic link, mirroring set_customer_name()."""
|
||||
customer_branch = (
|
||||
"when t1.quotation_to = 'Customer' then "
|
||||
"(select c.customer_name from `tabCustomer` c where c.name = t1.party_name)"
|
||||
)
|
||||
lead_branch = (
|
||||
"when t1.quotation_to = 'Lead' then "
|
||||
"(select coalesce(nullif(l.company_name, ''), l.lead_name) from `tabLead` l "
|
||||
"where l.name = t1.party_name)"
|
||||
)
|
||||
prospect_branch = "when t1.quotation_to = 'Prospect' then t1.party_name"
|
||||
branches = [customer_branch, lead_branch, prospect_branch]
|
||||
# CRM Deal ships with the CRM app; skip the branch when its table is absent
|
||||
if frappe.db.table_exists("CRM Deal"):
|
||||
branches.append(
|
||||
"when t1.quotation_to = 'CRM Deal' then "
|
||||
"(select d.organization from `tabCRM Deal` d where d.name = t1.party_name)"
|
||||
)
|
||||
|
||||
return "case " + " ".join(branches) + " end"
|
||||
|
||||
|
||||
def quotation_territory_expr():
|
||||
"""Only Customer and Lead carry a territory; other party types have none."""
|
||||
return (
|
||||
"case "
|
||||
"when t1.quotation_to = 'Customer' then "
|
||||
"(select c.territory from `tabCustomer` c where c.name = t1.party_name) "
|
||||
"when t1.quotation_to = 'Lead' then "
|
||||
"(select l.territory from `tabLead` l where l.name = t1.party_name) "
|
||||
"end"
|
||||
)
|
||||
|
||||
|
||||
def based_wise_columns_query(based_on, trans):
|
||||
based_on_details = {}
|
||||
|
||||
@@ -385,12 +420,14 @@ def based_wise_columns_query(based_on, trans):
|
||||
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
|
||||
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
|
||||
]
|
||||
# item_name is an editable per-line field, not functionally dependent on item_code, so it
|
||||
# is aggregated (one row per item_code) rather than added to GROUP BY (which would split
|
||||
# the row and change the MariaDB row count). See get_data's group-by query.
|
||||
based_on_details["based_on_select"] = "t2.item_code, Max(t2.item_name) as item_name,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_code"
|
||||
based_on_details["addl_tables"] = ""
|
||||
# item_name is stored per line and editable, so it is not functionally dependent on item_code
|
||||
# and Max() over it is a sort -- which MariaDB and PostgreSQL resolve differently. Read it
|
||||
# from the Item master instead: that IS functionally dependent on the grouped item_code, so
|
||||
# it can be grouped without splitting rows and is identical on both engines by construction.
|
||||
based_on_details["based_on_select"] = "t2.item_code, item_master.item_name as item_name,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_code, item_master.item_name"
|
||||
based_on_details["addl_tables"] = ",`tabItem` item_master"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t2.item_code = item_master.name"
|
||||
|
||||
elif based_on == "Item Group":
|
||||
based_on_details["based_on_cols"] = [
|
||||
@@ -425,9 +462,17 @@ def based_wise_columns_query(based_on, trans):
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details[
|
||||
"based_on_select"
|
||||
] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
|
||||
# a Quotation's party_name is a dynamic link, so no single master can be joined. Resolve
|
||||
# it through the quotation_to discriminator, mirroring Quotation.set_customer_name, and
|
||||
# group by it too: two parties of different types can share a name, and merging them
|
||||
# under one row was never right. Correlated only on grouped columns, so the query stays
|
||||
# valid under GROUP BY and free of any text sort.
|
||||
based_on_details["based_on_select"] = (
|
||||
f"t1.party_name, {quotation_party_name_expr()} as customer_name, "
|
||||
f"{quotation_territory_expr()} as territory,"
|
||||
)
|
||||
based_on_details["based_on_group_by"] = "t1.party_name, t1.quotation_to"
|
||||
based_on_details["addl_tables"] = ""
|
||||
else:
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
@@ -451,13 +496,19 @@ def based_wise_columns_query(based_on, trans):
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
# customer_name and territory are stored per transaction and editable, so they are not
|
||||
# functionally dependent on the customer and Max() over them is a text sort, which the
|
||||
# engines resolve differently. The Customer master's values ARE dependent on the grouped
|
||||
# key, so they can be grouped without splitting rows and agree on both engines.
|
||||
based_on_details["based_on_select"] = (
|
||||
"t1.customer, customer_master.customer_name as customer_name, "
|
||||
"customer_master.territory as territory,"
|
||||
)
|
||||
based_on_details[
|
||||
"based_on_select"
|
||||
] = "t1.customer, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory,"
|
||||
# territory (and customer_name) are not functionally dependent on the customer key, so they
|
||||
# are aggregated rather than grouped — one row per customer, matching the prior MariaDB output.
|
||||
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
|
||||
based_on_details["addl_tables"] = ""
|
||||
"based_on_group_by"
|
||||
] = "t1.customer, customer_master.customer_name, customer_master.territory"
|
||||
based_on_details["addl_tables"] = ",`tabCustomer` customer_master"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.customer = customer_master.name"
|
||||
|
||||
elif based_on == "Customer Group":
|
||||
based_on_details["based_on_cols"] = [
|
||||
@@ -490,14 +541,12 @@ def based_wise_columns_query(based_on, trans):
|
||||
"fieldname": "supplier_group",
|
||||
},
|
||||
]
|
||||
# supplier_name is a stored per-transaction field (not functionally dependent on supplier), so
|
||||
# it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped
|
||||
# by t1.supplier only. supplier_group comes from the joined master and is FD on supplier, so it
|
||||
# stays in GROUP BY (postgres-valid, no row split).
|
||||
based_on_details[
|
||||
"based_on_select"
|
||||
] = "t1.supplier, Max(t1.supplier_name) as supplier_name, t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.supplier, t3.supplier_group"
|
||||
# supplier_name is stored per transaction and editable, so Max() over it is a text sort that
|
||||
# the engines resolve differently. The Supplier master is already joined here as t3 and its
|
||||
# columns are functionally dependent on the grouped supplier, so both can simply be grouped:
|
||||
# no row split, and identical on both engines by construction.
|
||||
based_on_details["based_on_select"] = "t1.supplier, t3.supplier_name, t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.supplier, t3.supplier_name, t3.supplier_group"
|
||||
based_on_details["addl_tables"] = ",`tabSupplier` t3"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
|
||||
if not self.title:
|
||||
@@ -143,6 +144,15 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
def on_update(self):
|
||||
self.update_prospect()
|
||||
|
||||
def validate_qty(self):
|
||||
for item in self.items:
|
||||
if flt(item.qty) <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
|
||||
item.idx, item.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def map_fields(self):
|
||||
for field in self.meta.get_valid_columns():
|
||||
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
|
||||
|
||||
3358
erpnext/locale/ar.po
3358
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/bg.po
3342
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3556
erpnext/locale/bs.po
3556
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3342
erpnext/locale/cs.po
3342
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3376
erpnext/locale/da.po
3376
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/de.po
3366
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3376
erpnext/locale/eo.po
3376
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3366
erpnext/locale/es.po
3366
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user