Compare commits

..

31 Commits

Author SHA1 Message Date
mergify[bot]
cf42c52530 fix(quotation): carry forward communications from opportunity at after_insert (backport #57639) (#57642)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 20:56:17 +05:30
mergify[bot]
42d53783bb fix(plant_floor): add missing perm check on get_stock_summary (backport #57667) (#57669)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 15:26:15 +00:00
Mihir Kandoi
276d023946 Merge pull request #57654 from aerele/backport-57567-version-15-hotfix
fix: guard against None row in get_stock_balance_for (backport #57567)
2026-07-31 18:56:31 +05:30
pandiyan
72f293f131 fix: guard against None row in get_stock_balance_for (backport #57567)
get_stock_balance_for() takes row=None by default, but the batch-tracked
branch dereferenced it unconditionally while the two neighbouring row
accesses already guard. Calling it with a batch_no and no row raised
AttributeError: 'NoneType' object has no attribute 'use_serial_batch_fields'.

semgrep's missing-argument-type-hint rule matches the whole function body,
so touching any line inside it re-fingerprints the pre-existing untyped
arguments and reports them as introduced by this PR. Silenced with
nosemgrep instead of annotating: on a whitelisted method the hints are
enforced at runtime by pydantic, which is not a risk worth taking on v15.
2026-07-31 18:08:25 +05:30
mergify[bot]
b826b7c3e6 fix: use payment entry posting date for received amount exchange rate (backport #57660) (#57662)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-31 12:35:48 +00:00
mergify[bot]
b2918b8bb3 feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57402)
feat: make Shipping Rule Cost Center optional with company default fallback (#57355)

Cost Center on Shipping Rule is no longer mandatory. When left blank, the
applied shipping tax row falls back to the company default cost center,
avoiding the 'Cost Center is required for Profit and Loss account' error on
submit. The rule's project is also applied to the tax row.

(cherry picked from commit a47f25896b)

Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-07-31 14:51:43 +05:30
Mihir Kandoi
f727f863bb Merge pull request #57020 from frappe/mergify/bp/version-15-hotfix/pr-57015
fix(stock): pick list serial batch posting date (backport #57015)
2026-07-31 13:40:10 +05:30
mergify[bot]
bb36a4fd08 feat: auto-fill subscription accounting dimensions from plan with item fallback (backport #57615) (#57621)
* feat: auto-fill subscription accounting dimensions from plan with item fallback (#57615)

When a plan is selected in the Subscription's Plans table, the Subscription's
accounting dimensions (cost center and any custom dimensions) auto-fill from the
plan, falling back to the plan item's company default (selling cost center for a
Customer, buying for a Supplier). Only empty fields are filled. Stale async
responses are ignored so a quick re-pick of the plan can't be overwritten.

(cherry picked from commit 7febc28ed6)

# Conflicts:
#	erpnext/accounts/doctype/subscription/subscription.js
#	erpnext/accounts/doctype/subscription/subscription.py
#	erpnext/accounts/doctype/subscription/test_subscription.py

* fix: resolve backport merge conflicts for #57615

---------

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-07-31 12:38:58 +05:30
mergify[bot]
ebf5a462b3 fix: filter Accounts Receivable by invoice sales partner (backport #57628) (#57646)
fix: filter Accounts Receivable by invoice sales partner (#57628)

Filter Accounts Receivable and AR Summary on the Sales Invoice's own
sales_partner instead of the customer's default_sales_partner, and read
the Sales Partner column from the invoice. Returns are attributed to the
invoice they settle, matching how the Sales Person filter works.

(cherry picked from commit fd7765ac02)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-31 12:05:44 +05:30
Mihir Kandoi
bae02a6212 Merge pull request #57242 from frappe/mergify/bp/version-15-hotfix/pr-57223
fix(projects): include on hold status in project filters and reports (backport #57223)
2026-07-31 11:49:50 +05:30
Poovetha
54390bdeb9 fix(projects): add project filter
(cherry picked from commit 7248961568)
2026-07-31 11:09:53 +05:30
Poovetha
82850fb447 test(projects): add test to ensure on hold project retains status
(cherry picked from commit 79e5ccd370)
2026-07-31 11:09:53 +05:30
Poovetha
478426b436 fix(projects): include on hold status in project filters and reports
(cherry picked from commit 51a9fc0316)
2026-07-31 11:09:53 +05:30
ruthra kumar
83837868a0 Merge pull request #57640 from frappe/mergify/bp/version-15-hotfix/pr-57434
fix: update doc status in period closing voucher (backport #57434)
2026-07-31 10:57:12 +05:30
mergify[bot]
972a990b01 fix: do not fetch a random inventory account when multiple inventory accounts exist (backport #57626) (#57631)
* fix: do not fetch a random inventory account when multiple inventory accounts exist (#57626)

(cherry picked from commit 386a4ac1f0)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py

* chore: fix conflicts

Removed redundant test for valuation taxes in purchase receipt.

* fix: build warehouse account map only when perpetual inventory needs it

For asset purchase receipts or provisional accounting with perpetual
inventory disabled, GL entries do not use warehouse accounts. Building
the full warehouse account map in that case now throws when a company
has multiple inventory accounts and no default, breaking asset receipt
submission. Mirrors the gating on develop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: set default inventory account in valuation taxes LCV test

The conflict resolution kept the pre-backport copy of
test_valuation_taxes_lcv_repost_after_billing, which enables perpetual
inventory on _Test Company without configuring a default inventory
account. The test then failed on submit and leaked the perpetual
inventory flag, breaking every stock test that ran after it in the same
process. Restore the cherry-picked version from #57626.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:11:02 +00:00
nareshkannasln
310b9d4e65 fix: validate account frozen date
(cherry picked from commit b3c2ba5381)
2026-07-31 05:05:37 +00:00
mergify[bot]
68c24f3767 feat: status based bar colors in Work Order gantt view (backport #57634) (#57635)
feat: status based bar colors in Work Order gantt view (#57634)

(cherry picked from commit d59c5e36bc)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-31 09:59:07 +05:30
Mihir Kandoi
f2ec60b7a5 Merge pull request #57629 from frappe/mergify/bp/version-15-hotfix/pr-57616
fix: seed standard Item Groups under the existing tree root (backport #57616)
2026-07-30 19:46:00 +05:30
Mihir Kandoi
1602639a80 fix: resolve backport conflicts for version-15
install() holds the preset list inline on this branch, so the root is
resolved there instead of in get_preset_records. Dropping the preset-record
test with it -- there is no seam to call without running the whole installer.

The patch test is adapted to this branch: TestItem does not roll back between
tests, so it restores the original root name, and it passes parent_item_group
explicitly since ItemGroup.validate skips root-defaulting under
frappe.flags.in_test.
2026-07-30 19:22:29 +05:30
Mihir Kandoi
848335086c fix: seed standard Item Groups under the existing tree root
install_fixtures always inserted "All Item Groups" as a parentless group.
On a site where another app had already created the root, ItemGroup.validate
re-parented it, leaving a second group-root that held the standard groups
while the real root held everything else.

This is reproducible with the healthcare app on a non-English site: its
after_install seeds the root as _("All Item Groups"), so a pt-BR site gets
"Todos os Grupos de Itens" as the root before the setup wizard runs. The
split predates #57390 -- the old translated-name lookup resolved to the same
root and produced an identical tree.

Resolve the root once with get_root_of (falling back to the canonical English
name on fresh installs) and use it for the root record's exists-guard and the
standard groups' parent, matching Company.create_default_departments.

Patch merges an already-seeded "All Item Groups" into the root it sits under,
lifting its children and repointing every link.

Closes #57581

(cherry picked from commit e7088d8981)

# Conflicts:
#	erpnext/setup/doctype/item_group/test_item_group.py
#	erpnext/setup/setup_wizard/operations/install_fixtures.py
2026-07-30 13:41:45 +00:00
Shllokkk
9a596594da Merge pull request #57619 from Shllokkk/asset-manual-create-valuation-rate-v15
fix: source manually created asset value from valuation rate
2026-07-30 14:58:42 +05:30
Shllokkk
455d6d4ac1 fix: source manually created asset value from valuation rate 2026-07-30 14:31:54 +05:30
mergify[bot]
7cecff9fa4 fix: let Purchase Receipt cancel defer to Frappe's linked-document check (backport #57592) (#57602)
fix: let Purchase Receipt cancel defer to Frappe's linked-document check (#57592)

on_cancel pre-blocked cancellation with its own "Purchase Invoice is
already submitted" guard, duplicating the check Frappe already runs for any
submitted linked document. Drop the guard and the unused check_next_docstatus()
method it mirrored so the receipt defers to the framework: the Cancel All
Documents flow cancels the invoice first and then the receipt, and a direct
cancel is still rejected by Frappe's linked-document check.

Add a regression test that a direct cancel of a receipt with a submitted
invoice is rejected and rolls back, leaving no stray stock or GL entries.

(cherry picked from commit cfe18e8427)

# Conflicts:
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
#	erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-30 12:41:53 +05:30
mergify[bot]
94d63ebb49 fix(stock): value batched packed-item returns from the original bundle (backport #57327) (#57510)
fix(stock): value batched packed-item returns from the original bundle  (#57327)

* fix(stock): value batched packed-item returns from the original bundle

when a return delivery note or sales invoice bundle is built via the
use_serial_batch_fields / sle-driven path, its voucher_detail_no keeps the
packed item instead of being remapped to the parent dn/si item. the return
valuation lookup then misses and the bundle values at zero, so the sle
stock_value_difference stays wrong even after a repost.

resolve the original dn/si item via the packed item's parent_detail_docname
when the direct lookup fails, so the return values from the original outward
bundle on both submit and repost.

* test(stock): cover batched packed-item return valuation on repost

(cherry picked from commit d37e905322)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-30 07:02:41 +00:00
mergify[bot]
ade3f791a4 fix(stock): keep manufactured item rate at zero when inputs are free (backport #57334) (#57512)
fix(stock): keep manufactured item rate at zero when inputs are free  (#57334)

* fix(stock): keep manufactured item rate at zero when inputs are free

when a finished item is produced from raw materials consumed at zero
valuation, the incoming rate fell back to the item's own valuation
rate (or BOM cost), valuing free inputs as output and inflating the fg
value on every production run.

add has_consumption_basis() to detect when the consumed cost is known
even if it is zero (consumed rows present, or a consumption entry
exists for the work order). when it is, skip the get_valuation_rate and
BOM-cost fallbacks so a real cost of zero is preserved.

* test(stock): cover manufacture rate for zero-valued raw materials

- manufacture from a free input keeps fg basic_rate and sle
  incoming_rate/stock_value_difference at zero even when the fg already
  carries a valuation in the target warehouse
- material consumption on with no consumption entry does not fall back
  to bom/price-list rate for free inputs
- zero-valued consumption entry keeps the manufacture entry's fg rate
  at zero

(cherry picked from commit 73224d3650)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-07-30 09:25:52 +05:30
mergify[bot]
967955a926 refactor(accounts): repost accounting ledger (backport #56442) (#57584)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-29 15:23:30 +05:30
Krishna Pramod Shirsath
71b5f41556 Merge pull request #57589 from frappe/mergify/bp/version-15-hotfix/pr-57314
fix(italy): skip e-invoicing for opening invoices (backport #57314)
2026-07-29 13:44:08 +05:30
Krishna Shirsath
4f07e2503b fix(italy): skip e-invoicing for opening invoices
(cherry picked from commit f328018bfb)
2026-07-29 05:39:23 +00:00
Mihir Kandoi
16e14d70b5 Merge pull request #57586 from aerele/backport-57335-version-15-hotfix
refactor: reuse shared date range validation across reports
2026-07-29 10:46:55 +05:30
pandiyan
33d3696385 refactor: reuse shared date range validation across reports 2026-07-29 06:12:43 +05:30
Pandiyan P
a51750db56 fix(stock): pick list serial batch posting date (#57015)
* fix(stock): fall back to current date/time for serial and batch bundle posting datetime

Pick List has no posting_date/posting_time fields, so creating or updating a
Serial and Batch Bundle from a Pick List row crashed with
"TypeError: combine() argument 1 must be datetime.date, not None". Fall back
to today/now when the parent voucher doesn't carry its own posting date.

Fixes #56951

* fix(stock): accept a plain dict for add_serial_batch_ledgers' doc and child_row

The whitelisted add_serial_batch_ledgers only converted child_row into an
attribute-accessible frappe._dict when it arrived as a JSON string, and doc's
type hint only allowed Document | str. Frappe's JSON API delivers both as
plain dicts (see frappe.app.make_form_dict, which parses the request body
with orjson and only wraps the top-level dict, not nested values), so every
real request was rejected before the handler body ever ran: first with a
FrappeTypeError on doc, and once that's fixed, with an AttributeError on
child_row.serial_and_batch_bundle. parse_json already wraps a plain dict in
frappe._dict (and leaves a real Document instance untouched), so routing
child_row through it unconditionally fixes both.

(cherry picked from commit 7e46be2a33)
2026-07-10 06:11:45 +00:00
51 changed files with 1670 additions and 300 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.118.2"
__version__ = "15.112.0"
def get_default_company(user=None):

View File

@@ -3316,13 +3316,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":

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
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
@@ -46,6 +47,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(
@@ -147,6 +156,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()

View File

@@ -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();
},
});
},
});

View File

@@ -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",
@@ -80,4 +131,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -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")
from erpnext.stock import get_warehouse_account_map
@@ -28,6 +35,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
@@ -37,6 +49,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()
@@ -73,8 +90,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]
@@ -139,80 +200,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.flags.in_test,
queue="long",
timeout=1500,
job_id=_repost_job_id(repost_doc_name),
deduplicate=True,
enqueue_after_commit=True,
now=frappe.flags.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):

View File

@@ -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];
},
};

View File

@@ -1,20 +1,35 @@
# 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.tests.utils import FrappeTestCase, change_settings
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.test.accounts_mixin import AccountsTestMixin
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
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
SIMULATED_FAILURE = "Simulated repost failure"
class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
def setUp(self):
@@ -26,8 +41,8 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
def test_01_basic_functions(self):
si = create_sales_invoice(
def make_invoice(self, **kwargs):
return create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
@@ -35,8 +50,71 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
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 = self.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=self.company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": self.company,
"fiscal_year": fy[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"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,
@@ -70,51 +148,24 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to})
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))
.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))
.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=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
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 = self.deferred_revenue
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 = self.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])
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self):
@@ -122,86 +173,29 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == self.company).run()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
fy = get_fiscal_year(today(), company=self.company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": self.company,
"fiscal_year": fy[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"remarks": "test",
}
)
pcv.save().submit()
si = self.make_invoice()
pcv = self.make_period_closing_voucher()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.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=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
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 = self.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=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
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 = self.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}))
@@ -247,11 +241,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
another_provisional_account,
)
repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = self.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 = [
@@ -272,6 +262,279 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
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")
# the `discard` flow (and the `on_discard` hook it triggers) only exists on v16,
# so there is nothing to assert here on v15
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})
)
@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 == self.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 = [

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType",
@@ -7,28 +8,63 @@
"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": "2023-07-04 14:15:51.165584",
"modified": "2026-07-29 02:41:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger Items",
@@ -37,4 +73,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -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

View File

@@ -2802,12 +2802,15 @@ class TestSalesInvoice(FrappeTestCase):
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",
},
)
frappe.db.set_value(
"Company",
@@ -2852,6 +2855,7 @@ class TestSalesInvoice(FrappeTestCase):
# 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):

View File

@@ -80,8 +80,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"reqd": 1
"options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -139,18 +138,20 @@
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-truck",
"idx": 1,
"modified": "2019-05-25 23:12:26.156405",
"links": [],
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -196,5 +197,8 @@
"write": 1
}
],
"sort_order": "ASC"
}
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}

View File

@@ -36,18 +36,17 @@ class ShippingRule(Document):
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
ShippingRuleCondition,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
ShippingRuleCountry,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
account: DF.Link
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
company: DF.Link
conditions: DF.Table[ShippingRuleCondition]
cost_center: DF.Link
cost_center: DF.Link | None
countries: DF.Table[ShippingRuleCountry]
disabled: DF.Check
label: DF.Data
project: DF.Link | None
shipping_amount: DF.Currency
shipping_rule_type: DF.Literal["Selling", "Buying"]
# end: auto-generated types

View File

@@ -96,3 +96,29 @@ 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);
}
}
},
});
},
});

View File

@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account_currency
from erpnext.stock.doctype.item.item import get_item_defaults
class InvoiceCancelled(frappe.ValidationError):
@@ -747,6 +748,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 == "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

View File

@@ -17,7 +17,7 @@ from frappe.utils.data import (
nowdate,
)
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
from erpnext.accounts.doctype.subscription.subscription import get_plan_dimensions, get_prorata_factor
test_dependencies = ("UOM", "Item Group", "Item")
@@ -583,6 +583,48 @@ class TestSubscription(FrappeTestCase):
subscription.process(nowdate())
self.assertEqual(len(subscription.invoices), 1)
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",
"default_warehouse": "_Test Warehouse - _TC",
"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_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")

View File

@@ -106,6 +106,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()
@@ -241,6 +242,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:
@@ -469,7 +476,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)
@@ -903,6 +910,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 = []
@@ -1005,15 +1028,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")
@@ -1113,9 +1127,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,
@@ -1242,7 +1253,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(

View File

@@ -7,6 +7,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
@@ -1303,3 +1304,61 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
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)

View File

@@ -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 = []
@@ -191,7 +191,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(

View File

@@ -193,3 +193,42 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
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)

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.utils import flt, getdate
from pypika import Tuple
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.accounts.utils import get_currency_precision
@@ -33,9 +34,7 @@ def execute(filters=None):
def validate_filters(filters):
"""Validate if dates are properly set"""
filters = frappe._dict(filters or {})
if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date"))
validate_mandatory_date_range(filters or {})
def get_result(filters, tds_accounts, tax_category_map, net_total_map):

View File

@@ -5,6 +5,7 @@ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details imp
get_result,
get_tds_docs,
)
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.accounts.utils import get_fiscal_year
@@ -33,8 +34,7 @@ def execute(filters=None):
def validate_filters(filters):
"""Validate if dates are properly set and lie in the same fiscal year"""
if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date"))
validate_mandatory_date_range(filters)
from_year = get_fiscal_year(filters.from_date)[0]
to_year = get_fiscal_year(filters.to_date)[0]

View File

@@ -1,4 +1,5 @@
import frappe
from frappe import _
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import flt, formatdate, get_datetime_str, get_table_name
@@ -16,6 +17,19 @@ from erpnext.setup.utils import get_exchange_rate
__exchange_rates = {}
def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"):
from_date = filters.get(from_field)
to_date = filters.get(to_field)
if not from_date or not to_date:
frappe.throw(
_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))
)
if from_date > to_date:
frappe.throw(_("From Date must be before To Date"))
def get_currency(filters):
"""
Returns a dictionary containing currency information. The keys of the dict are

View File

@@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
return {
"company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"),
"gross_purchase_amount": flt(first_item.base_net_amount),
"gross_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"),

View File

@@ -306,7 +306,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
def get_project_name(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
):
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
@@ -321,7 +323,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company"))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj)

View File

@@ -177,13 +177,18 @@ class StockController(AccountsController):
)
is_asset_pr = any(d.get("is_fixed_asset") for d in self.get("items"))
need_inventory_map = (self.get_stock_items() or self.get("packed_items")) and cint(
erpnext.is_perpetual_inventory_enabled(self.company)
)
if (
cint(erpnext.is_perpetual_inventory_enabled(self.company))
or provisional_accounting_for_non_stock_items
or is_asset_pr
):
warehouse_account = get_warehouse_account_map(self.company)
warehouse_account = frappe._dict()
if need_inventory_map:
warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus == 1:
if not gl_entries:

View File

@@ -67,6 +67,14 @@ class PlantFloor(Document):
@frappe.whitelist()
def get_stock_summary(warehouse, start=0, item_code=None, item_group=None):
frappe.has_permission("Warehouse", doc=warehouse, throw=True)
if item_code:
frappe.has_permission("Item", doc=item_code, throw=True)
if item_group:
frappe.has_permission("Item Group", doc=item_group, throw=True)
stock_details = get_stock_details(warehouse, start=start, item_code=item_code, item_group=item_group)
max_count = 0.0

View File

@@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = {
],
get_events_method: "frappe.desk.calendar.get_events",
};
const WORK_ORDER_GANTT_COLORS = {
Draft: "red",
Stopped: "red",
"Not Started": "red",
"In Process": "orange",
Completed: "green",
"Stock Reserved": "blue",
"Stock Partially Reserved": "orange",
Cancelled: "gray",
};
if (!frappe.views.GanttView.prototype._work_order_status_colors) {
frappe.views.GanttView.prototype._work_order_status_colors = true;
const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks;
frappe.views.GanttView.prototype.prepare_tasks = function () {
prepare_tasks.call(this);
if (this.doctype === "Work Order") {
set_work_order_bar_classes(this);
}
};
const set_colors = frappe.views.GanttView.prototype.set_colors;
frappe.views.GanttView.prototype.set_colors = function () {
set_colors.call(this);
if (this.doctype === "Work Order") {
set_work_order_bar_styles(this);
}
};
}
function set_work_order_bar_classes(view) {
view.tasks.forEach((task, idx) => {
const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status];
if (color) {
task.custom_class = "wo-" + color;
}
});
}
function set_work_order_bar_styles(view) {
const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))]
.map(
(color) => `
.gantt .bar-wrapper.wo-${color} .bar {
fill: var(--${color}-300);
}
.gantt .bar-wrapper.wo-${color} .bar-progress {
fill: var(--${color}-300);
}
`
)
.join("");
view.$result.prepend(`<style>${style}</style>`);
}

View File

@@ -445,3 +445,5 @@ erpnext.patches.v16_0.backfill_pick_list_transferred_qty
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v15_0.fix_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root

View File

@@ -0,0 +1,25 @@
import frappe
from frappe.query_builder.functions import Coalesce
def execute():
"""Backfill the statuses of documents reposted before those fields existed.
Without it they show up as drafts and are offered a `Start Reposting` button that would
repost vouchers which are already reposted.
"""
ral = frappe.qb.DocType("Repost Accounting Ledger")
items = frappe.qb.DocType("Repost Accounting Ledger Items")
reposted = (
frappe.qb.from_(ral).select(ral.name).where((ral.docstatus == 1) & (Coalesce(ral.status, "") == ""))
)
frappe.qb.update(items).set(items.status, "Reposted").where(items.parent.isin(reposted)).run()
for docstatus, status in ((1, "Completed"), (2, "Cancelled")):
(
frappe.qb.update(ral)
.set(ral.status, status)
.where((ral.docstatus == docstatus) & (Coalesce(ral.status, "") == ""))
.run()
)

View File

@@ -0,0 +1,23 @@
import frappe
from frappe.utils.nestedset import get_root_of
SEEDED_ROOT = "All Item Groups"
def execute():
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
Setup seeding always inserted "All Item Groups" as a parentless group. On a
site where another app had already created the root (under a translated
name), it was re-parented instead, leaving a second group-root holding the
standard Item Groups.
"""
root = get_root_of("Item Group")
if not root or root == SEEDED_ROOT:
return
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
return
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)

View File

@@ -282,6 +282,23 @@ class TestProject(FrappeTestCase):
project.save()
self.assertEqual(project.percent_complete, 100)
def test_on_hold_project_keeps_status(self):
project, tasks = self._project_with_tasks("Task Completion", 4)
# an On hold project is not auto-flipped to Completed even at 100%
project.status = "On hold"
for task in tasks:
frappe.db.set_value("Task", task, "status", "Completed")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 100)
self.assertEqual(project.status, "On hold")
# nor auto-flipped back to Open when below 100%
frappe.db.set_value("Task", tasks[0], "status", "Open")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 75)
self.assertEqual(project.status, "On hold")
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""

View File

@@ -15,6 +15,12 @@ frappe.ui.form.on("Task", {
},
onload: function (frm) {
frm.set_query("project", function () {
return {
query: "erpnext.controllers.queries.get_project_name",
};
});
frm.set_query("task", "depends_on", function () {
let filters = {
name: ["!=", frm.doc.name],

View File

@@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", {
return {
filters: {
company: frm.doc.company,
status: "Open",
},
};
};
@@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", {
return {
filters: {
customer: doc.customer,
status: "Open",
},
};
});

View File

@@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = {
fieldname: "status",
label: __("Status"),
fieldtype: "Select",
options: "\nOpen\nCompleted\nCancelled",
options: "\nOpen\nOn hold\nCompleted\nCancelled",
default: "Open",
},
{

View File

@@ -238,7 +238,7 @@ def get_invoice_summary(items, taxes):
# Preflight for successful e-invoice export.
def sales_invoice_validate(doc):
# Validate company
if doc.doctype != "Sales Invoice":
if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes":
return
if not doc.company_address:
@@ -322,7 +322,7 @@ def sales_invoice_validate(doc):
# Ensure payment details are valid for e-invoice.
def sales_invoice_on_submit(doc, method):
# Validate payment details
if get_company_country(doc.company) not in [
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
"Italy",
"Italia",
"Italian Republic",
@@ -388,7 +388,7 @@ def generate_single_invoice(docname):
# Delete e-invoice attachment on cancel.
def sales_invoice_on_cancel(doc, method):
if get_company_country(doc.company) not in [
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
"Italy",
"Italia",
"Italian Republic",

View File

@@ -147,6 +147,9 @@ class Quotation(SellingController):
make_packing_list(self)
def after_insert(self):
self.carry_forward_communication()
def before_submit(self):
self.set_has_alternative_item()
@@ -292,7 +295,6 @@ class Quotation(SellingController):
# update enquiry status
self.update_opportunity("Quotation")
self.update_lead()
self.carry_forward_communication()
def on_cancel(self):
if self.lost_reasons:

View File

@@ -16,6 +16,8 @@ from frappe.utils.nestedset import (
test_records = frappe.get_test_records("Item Group")
TRANSLATED_ROOT = "Todos os Grupos de Itens"
class TestItem(unittest.TestCase):
def test_basic_tree(self, records=None):
@@ -234,3 +236,46 @@ class TestItem(unittest.TestCase):
"_Test Item Group B - 3",
merge=True,
)
def test_patch_merges_seeded_root_into_existing_root(self):
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
self.nest_root_under(TRANSLATED_ROOT)
self.assertEqual(
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
)
execute()
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT])
self.assertEqual(
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
)
self.test_basic_tree()
# restore the original root name for the tests that follow
frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups")
self.assertEqual(self.get_root_names(), ["All Item Groups"])
self.test_basic_tree()
def nest_root_under(self, new_root):
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
frappe.get_doc(
{
"doctype": "Item Group",
"item_group_name": new_root,
"is_group": 1,
"parent_item_group": "All Item Groups",
}
).insert()
ig = frappe.qb.DocType("Item Group")
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
rebuild_tree("Item Group", "parent_item_group")
def get_root_names(self):
return frappe.db.sql_list(
"""select name from `tabItem Group` where ifnull(parent_item_group, '')=''"""
)

View File

@@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
)
from frappe.desk.page.setup_wizard.setup_wizard import make_records
from frappe.utils import cstr, getdate
from frappe.utils.nestedset import get_root_of
from erpnext.accounts.doctype.account.account import RootNotEditable
from erpnext.regional.address_template.setup import set_up_address_templates
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
def install(country=None):
root_item_group = get_root_of("Item Group") or _("All Item Groups")
records = [
# ensure at least an empty Address Template exists for this Country
{"doctype": "Address Template", "country": country},
# item group
{
"doctype": "Item Group",
"item_group_name": _("All Item Groups"),
"item_group_name": root_item_group,
"is_group": 1,
"parent_item_group": "",
"__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
},
{
"doctype": "Item Group",
"item_group_name": _("Products"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
"show_in_website": 1,
},
{
"doctype": "Item Group",
"item_group_name": _("Raw Material"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Services"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Sub Assemblies"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Consumable"),
"is_group": 0,
"parent_item_group": _("All Item Groups"),
"parent_item_group": root_item_group,
},
# Stock Entry Type
{

View File

@@ -79,10 +79,13 @@ def get_warehouse_account(warehouse, warehouse_account=None):
account = get_company_default_inventory_account(warehouse.company)
if not account and warehouse.company:
account = frappe.db.get_value(
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name"
inventory_accounts = frappe.get_all(
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name"
)
if len(inventory_accounts) == 1:
account = inventory_accounts[0]
if not account and warehouse.company and not warehouse.is_group:
frappe.throw(
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(

View File

@@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(gle_warehouse_amount, 1400)
def test_return_bundle_voucher_detail_no_as_packed_item(self):
"""Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
warehouse = "_Test Warehouse - _TC"
packed_item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BATCH-DN-RET-VDN-.#####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
make_product_bundle(bundle_item, [packed_item], qty=20)
make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
return_dn = make_sales_return(dn.name)
return_dn.items[0].qty = -2
return_dn.submit()
return_dn.reload()
packed_row = return_dn.packed_items[0]
bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
# Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
bundle.db_set("voucher_detail_no", packed_row.name)
bundle.db_set({"avg_rate": 0, "total_amount": 0})
for entry in bundle.entries:
entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
packed_row.db_set("incoming_rate", 0)
frappe.db.set_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": return_dn.name,
"item_code": packed_item,
"is_cancelled": 0,
},
{"incoming_rate": 0, "stock_value_difference": 0},
)
frappe.get_doc(
doctype="Repost Item Valuation",
based_on="Transaction",
voucher_type="Delivery Note",
voucher_no=return_dn.name,
posting_date=return_dn.posting_date,
posting_time=return_dn.posting_time,
).submit()
bundle.reload()
self.assertEqual(flt(bundle.avg_rate), 35)
incoming_rate, stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": return_dn.name,
"item_code": packed_item,
"is_cancelled": 0,
},
["incoming_rate", "stock_value_difference"],
)
self.assertEqual(flt(incoming_rate), 35)
self.assertEqual(flt(stock_value_difference), 1400)
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -194,8 +194,10 @@ class TestLandedCostVoucher(FrappeTestCase):
epi = is_perpetual_inventory_enabled(company_a)
company_doc = frappe.get_doc("Company", company_a)
old_inventory_account = company_doc.default_inventory_account
company_doc.enable_perpetual_inventory = 1
company_doc.stock_received_but_not_billed = srbnb
company_doc.default_inventory_account = "Stock In Hand - _TC"
company_doc.save()
pr = make_purchase_receipt(
@@ -223,7 +225,11 @@ class TestLandedCostVoucher(FrappeTestCase):
distribute_landed_cost_on_items(lcv)
lcv.submit()
frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
frappe.db.set_value(
"Company",
company_a,
{"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account},
)
frappe.local.enable_perpetual_inventory = {}
def test_landed_cost_voucher_for_zero_purchase_rate(self):

View File

@@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController):
self.set_consumed_qty_in_subcontract_order()
self.reserve_stock_for_sales_order()
def check_next_docstatus(self):
submit_rv = frappe.db.sql(
"""select t1.name
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
(self.name),
)
if submit_rv:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
def on_cancel(self):
super().on_cancel()
self.check_on_hold_or_closed_status()
# Check if Purchase Invoice has been submitted against current Purchase Order
submitted = frappe.db.sql(
"""select t1.name
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
self.name,
)
if submitted:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
self.update_prevdoc_status()
self.update_billing_status()

View File

@@ -3263,11 +3263,14 @@ class TestPurchaseReceipt(FrappeTestCase):
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company")
frappe.local.enable_perpetual_inventory["_Test Company"] = 1
old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account")
frappe.db.set_value(
"Company",
"_Test Company",
"stock_received_but_not_billed",
"Stock Received But Not Billed - _TC",
{
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"default_inventory_account": "Stock In Hand - _TC",
},
)
pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1)
@@ -3296,13 +3299,14 @@ class TestPurchaseReceipt(FrappeTestCase):
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=False)
warehouse_account = get_warehouse_account_map("_Test Company")
expected_gle = (
("Stock Received But Not Billed - _TC", 0, 10000, "Main - _TC"),
("Freight and Forwarding Charges - _TC", 0, 2000, "Main - _TC"),
("Expenses Included In Valuation - _TC", 0, 2000, "Main - _TC"),
(warehouse_account[pr.items[0].warehouse]["account"], 14000, 0, "Main - _TC"),
("Stock Received But Not Billed - _TC", 0.0, 10000.0, "Main - _TC"),
("Freight and Forwarding Charges - _TC", 0.0, 2000.0, "Main - _TC"),
("Expenses Included In Valuation - _TC", 0.0, 2000.0, "Main - _TC"),
(warehouse_account[pr.items[0].warehouse]["account"], 14000.0, 0.0, "Main - _TC"),
)
self.assertSequenceEqual(expected_gle, gl_entries)
self.assertCountEqual(expected_gle, gl_entries)
frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory
frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account)
def test_manufacturing_and_expiry_date_for_batch(self):
item = make_item(
@@ -5446,6 +5450,32 @@ class TestPurchaseReceipt(FrappeTestCase):
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
pr = make_purchase_receipt()
pi = make_purchase_invoice(pr.name)
pi.insert()
pi.submit()
pr.reload()
status_before = pr.status
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
frappe.db.savepoint("before_blocked_cancel")
with self.assertRaises(frappe.LinkExistsError) as cm:
pr.cancel()
self.assertIn(pi.name, str(cm.exception))
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
pr.reload()
self.assertEqual(pr.docstatus, 1)
self.assertEqual(pr.status, status_before)
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -505,6 +505,11 @@ class SerialandBatchBundle(Document):
self.child_table, self.voucher_detail_no, field
)
if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
# Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
# as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
return_against_voucher_detail_no = self.get_return_against_packed_item(field)
filters = [
["Serial and Batch Bundle", "voucher_no", "=", return_against],
["Serial and Batch Entry", "docstatus", "=", 1],
@@ -548,6 +553,16 @@ class SerialandBatchBundle(Document):
return valuation_details
def get_return_against_packed_item(self, field):
"""Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
parent_detail_docname = frappe.db.get_value(
"Packed Item", self.voucher_detail_no, "parent_detail_docname"
)
if not parent_detail_docname:
return
return frappe.db.get_value(self.child_table, parent_detail_docname, field)
def get_legacy_valuation_rate_for_return_entry(
self, return_against, return_against_voucher_detail_no, return_warehouse=None
):

View File

@@ -1321,6 +1321,7 @@ class StockEntry(StockController):
# Set rate for outgoing items
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item)
has_consumption_basis = self.has_consumption_basis()
items = []
# Set basic rate for incoming items
@@ -1328,6 +1329,8 @@ class StockEntry(StockController):
if d.s_warehouse or d.set_basic_rate_manually:
continue
rate_derived_from_consumption = False
if d.allow_zero_valuation_rate:
d.basic_rate = 0.0
items.append(d.item_code)
@@ -1335,12 +1338,17 @@ class StockEntry(StockController):
elif d.is_finished_item:
if self.purpose == "Manufacture":
d.basic_rate = self.get_basic_rate_for_manufactured_item(
finished_item_qty, outgoing_items_cost
finished_item_qty, outgoing_items_cost, has_consumption_basis
)
rate_derived_from_consumption = has_consumption_basis
elif self.purpose == "Repack":
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
# Repack rate comes from consumed source-warehouse rows, not consumption entries
rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items"))
if not d.basic_rate and not d.allow_zero_valuation_rate:
# A rate of zero derived from the consumed items is their actual cost, not a missing
# rate. Falling back to the item's valuation here would value free inputs as output.
if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption:
if self.is_new():
raise_error_if_no_rate = False
@@ -1375,6 +1383,31 @@ class StockEntry(StockController):
frappe.msgprint(message, alert=True)
def has_consumption_basis(self) -> bool:
"""Whether the cost of the consumed items is known, even when that cost is zero."""
if any(d.s_warehouse for d in self.get("items")):
return True
settings = frappe.get_single("Manufacturing Settings")
if settings.material_consumption and settings.get_rm_cost_from_consumption_entry and self.work_order:
return bool(self.get_consumption_entries())
return False
def get_consumption_entries(self) -> list[str]:
# Cached: queried in both has_consumption_basis() and get_basic_rate_for_manufactured_item()
if getattr(self, "_consumption_entries", None) is None:
self._consumption_entries = frappe.get_all(
"Stock Entry",
filters={
"docstatus": 1,
"work_order": self.work_order,
"purpose": "Material Consumption for Manufacture",
},
pluck="name",
)
return self._consumption_entries
def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
outgoing_items_cost = 0.0
for d in self.get("items"):
@@ -1428,21 +1461,16 @@ class StockEntry(StockController):
)
return flt(outgoing_items_cost / total_fg_qty)
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
def get_basic_rate_for_manufactured_item(
self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False
) -> float:
settings = frappe.get_single("Manufacturing Settings")
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item])
if settings.material_consumption:
if settings.get_rm_cost_from_consumption_entry and self.work_order:
# Validate only if Material Consumption Entry exists for the Work Order.
if frappe.db.exists(
"Stock Entry",
{
"docstatus": 1,
"work_order": self.work_order,
"purpose": "Material Consumption for Manufacture",
},
):
if self.get_consumption_entries():
for item in self.items:
if not item.is_finished_item and not item.is_scrap_item:
label = frappe.get_meta(settings.doctype).get_label(
@@ -1489,7 +1517,9 @@ class StockEntry(StockController):
)
).run()[0][0] or 0
elif not outgoing_items_cost:
# Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a
# real cost, so substituting BOM rates would value free inputs as output.
elif not outgoing_items_cost and not has_consumption_basis:
bom_items = self.get_bom_raw_materials(finished_item_qty)
outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()])

View File

@@ -2574,6 +2574,149 @@ class TestStockEntry(FrappeTestCase):
material_request.reload()
self.assertEqual(material_request.transfer_status, "Completed")
def test_manufacture_with_zero_valued_raw_material(self):
# A finished good produced from free inputs is worth nothing. Falling back to the item's
# own valuation would create value out of nothing and inflate it on every production run.
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
fg_warehouse = "Finished Goods - _TC"
rm_receipt = make_stock_entry(item_code=rm_item, target=warehouse, qty=100, rate=0, do_not_save=True)
rm_receipt.items[0].allow_zero_valuation_rate = 1
rm_receipt.save()
rm_receipt.submit()
# the finished good already carries a valuation in the target warehouse
make_stock_entry(item_code=fg_item, target=fg_warehouse, qty=10, rate=100)
se = frappe.new_doc("Stock Entry")
se.purpose = se.stock_entry_type = "Manufacture"
se.company = "_Test Company"
se.append(
"items",
{"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1},
)
se.append(
"items",
{
"item_code": fg_item,
"t_warehouse": fg_warehouse,
"qty": 10,
"is_finished_item": 1,
"conversion_factor": 1,
},
)
se.save()
self.assertEqual(se.items[0].basic_amount, 0)
self.assertEqual(se.items[1].basic_rate, 0)
self.assertEqual(se.items[1].basic_amount, 0)
se.submit()
fg_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name, "item_code": fg_item, "is_cancelled": 0},
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(fg_sle.incoming_rate, 0)
self.assertEqual(fg_sle.stock_value_difference, 0)
def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no):
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
receipt = make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, rate=0, do_not_save=True)
receipt.items[0].allow_zero_valuation_rate = 1
receipt.save()
receipt.submit()
wo = make_wo_order_test_record(production_item=fg_item, bom_no=bom_no, qty=10)
transfer = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Material Transfer for Manufacture", 10))
transfer.items[0].s_warehouse = "Stores - _TC"
transfer.insert().submit()
return wo
@change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 0}
)
def test_manufacture_does_not_fall_back_to_bom_cost_for_free_raw_material(self):
# The BOM is only an estimate for when nothing was consumed. Items that were consumed and
# cost nothing are a real cost, so a BOM rate must not stand in for them.
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
rm_item = make_item(properties={"is_stock_item": 1}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": rm_item,
"price_list": "_Test Price List India",
"price_list_rate": 150,
"buying": 1,
}
).insert()
# price the BOM off the price list so that it carries a rate the free stock does not
bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_save=True)
bom.rm_cost_as_per = "Price List"
bom.buying_price_list = "_Test Price List India"
bom.currency = "INR"
bom.save()
bom.submit()
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom.name)
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
manufacture.save()
fg_row = next(d for d in manufacture.items if d.is_finished_item)
self.assertEqual(fg_row.basic_rate, 0)
self.assertEqual(fg_row.basic_amount, 0)
@change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
)
def test_manufacture_with_zero_valued_consumption_entry(self):
# The raw material is consumed by a separate entry, so the Manufacture entry carries no
# consumed rows of its own. Its cost is still known, and it is zero.
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
rm_item = make_item(properties={"is_stock_item": 1}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
# the finished good already carries a valuation in the work order's target warehouse
make_stock_entry(item_code=fg_item, target="_Test Warehouse 1 - _TC", qty=10, rate=100)
bom = make_bom(item=fg_item, raw_materials=[rm_item]).name
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom)
consumption = frappe.get_doc(
make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10)
)
consumption.insert().submit()
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
manufacture.save()
fg_row = next(d for d in manufacture.items if d.is_finished_item)
self.assertEqual(fg_row.basic_rate, 0)
self.assertEqual(fg_row.basic_amount, 0)
def test_disassemble_entry_without_wo(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom

View File

@@ -1292,6 +1292,7 @@ def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_d
return flt(result[0][0]) if result and result[0][0] else 0.0
# nosemgrep: missing-argument-type-hint
@frappe.whitelist()
def get_stock_balance_for(
item_code: str,
@@ -1364,7 +1365,7 @@ def get_stock_balance_for(
or 0
)
if row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
if row and row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
rate = get_incoming_rate(
frappe._dict(
{

View File

@@ -103,6 +103,44 @@ class TestWarehouse(FrappeTestCase):
children = get_children("Warehouse", parent=company, company=company, is_root=True)
self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children))
def test_inventory_account_fallback_with_multiple_stock_accounts(self):
from erpnext.stock import get_warehouse_account
company = create_inventory_fallback_company()
frappe.db.set_value("Company", company, "default_inventory_account", None)
if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"):
frappe.delete_doc("Account", "Extra Inventory Account - _TCIF")
warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0})
single_account = frappe.db.get_value(
"Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name"
)
self.assertEqual(get_warehouse_account(warehouse), single_account)
create_account(
account_name="Extra Inventory Account",
parent_account=frappe.db.get_value("Account", single_account, "parent_account"),
account_type="Stock",
company=company,
)
self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse)
def create_inventory_fallback_company():
company = "_Test Company Inventory Fallback"
if not frappe.db.exists("Company", company):
frappe.get_doc(
{
"doctype": "Company",
"company_name": company,
"abbr": "_TCIF",
"default_currency": "INR",
"enable_perpetual_inventory": 0,
"country": "India",
}
).insert(ignore_permissions=True)
return company
def create_warehouse(warehouse_name, properties=None, company=None):
if not company:

View File

@@ -8,6 +8,7 @@ from frappe.utils import add_to_date, cint, flt, get_datetime, get_table_name, g
from frappe.utils.deprecations import deprecated
from pypika import functions as fn
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
SLE_COUNT_LIMIT = 100_000
@@ -29,8 +30,7 @@ def execute(filters=None):
_("Please select either the Item or Warehouse or Warehouse Type filter to generate the report.")
)
if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date"))
validate_mandatory_date_range(filters)
float_precision = cint(frappe.db.get_default("float_precision")) or 3

View File

@@ -9,6 +9,7 @@ from frappe import _
from frappe.utils import date_diff
from erpnext.accounts.report.general_ledger.general_ledger import get_gl_entries
from erpnext.accounts.report.utils import validate_mandatory_date_range
Filters = frappe._dict
Row = frappe._dict
@@ -34,8 +35,7 @@ def update_filters_with_account(filters: Filters) -> None:
def validate_filters(filters: Filters) -> None:
if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date"))
validate_mandatory_date_range(filters)
def get_columns() -> Columns: