Compare commits

...

66 Commits

Author SHA1 Message Date
frappe-pr-bot
b4a473f3df chore: update POT file 2026-09-13 10:03:53 +00:00
Sudharsanan Ashok
fe25746feb fix(subcontracting): validate project across the subcontracting flow (#58965) 2026-09-13 11:58:56 +05:30
Mihir Kandoi
f5f956c4dd test: stop six tests from passing without running (#59025)
* test: stop four tests from passing without running

Three advisory-lock tests return early on MariaDB:

    if frappe.db.db_type != "postgres":
        return

A bare return reports the test as passed, so the MariaDB CI job shows
green for a test it never ran. skipTest reports it as skipped.

test_stock_reco_with_opening_stock_with_diff_inventory returned early
when the custom "Plant" DocType already existed. DocType creation is
DDL and survives the test transaction, so the test ran once on a fresh
site and silently did nothing on every run after that. Create the
DocType only when it is missing and let the test run either way.

Its closing loop also asserted inside an if/elif over the ledger rows,
which verified nothing if the dimension came back unset. Compare the
whole {plant: qty} mapping instead.

* test: give the job card validator tests a real job card

Both tests looked for a submitted Job Card left behind by another test
and returned when they did not find one:

    jc_name = frappe.db.get_value("Job Card", {"docstatus": 1})
    if not jc_name:
        return  # skip if no job cards in test data

Run in isolation they asserted nothing and still reported a pass, and
they were the only coverage for validate_job_card_fg_item and
validate_job_card_item.

Move them to test_job_card.py, where the Work Order and BOM fixtures
that produce Job Cards already live, and build the Job Card in the test.
The finished-good case needs a card that carries one, so it goes through
a track_semi_finished_goods BOM. Both now assert on the message text, and
both fail if the validator body is removed.
2026-09-12 14:23:11 +05:30
Mihir Kandoi
fe9d6e5a57 test: remove seven tests that cannot fail (#59024)
Four tests in test_stock_entry.py each cover only an early-return guard:

    def test_validate_job_card_item_skips_when_no_job_card(self):
        se = frappe.new_doc("Stock Entry")
        se.job_card = None
        se.validate_job_card_item()  # must not raise

That exercises `if not self.job_card: return` and nothing else. The
mismatch tests next to them already cover the behaviour these validators
actually implement.

Three tests in test_payment_request.py assert against their own mock.
_is_v2_gateway delegates to payments.utils.is_v2_gateway; all three mock
that delegate to return False and then assert the result is False, for
inputs (None, "", "NonExistentGateway12345") that take an identical code
path. The mock decides the outcome, so the assertion holds regardless of
what ERPNext does. The three tests covering the real branches --
delegation, a False delegate, and the exception fallback -- are kept.
2026-09-12 14:23:11 +05:30
Mihir Kandoi
bec627c3eb refactor(manufacturing): rename secondary item valuation type to "% of Component Cost" (#59021) 2026-09-12 06:55:42 +05:30
Mihir Kandoi
bee358ea25 fix(support): stop the SLA link filter patches from aborting migrate on PostgreSQL (#59018) 2026-09-12 06:20:50 +05:30
Pandiyan P
000dcfc23d feat(stock): show reserved stock in batch balance history (#59008) 2026-09-11 18:21:16 +05:30
Pandiyan P
825d24f406 fix(stock): calculate batch bundle valuation per unit (#58994) 2026-09-11 12:50:49 +00:00
Pandiyan P
e6f431a8d6 fix: prevent disabled items from being used in BOM (#58997)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-11 12:20:30 +00:00
Mihir Kandoi
5dfd21cce6 fix(selling): list billable sales orders oldest first (#59010) 2026-09-11 12:05:02 +00:00
Shllokkk
6f5f2cfce1 Merge pull request #58979 from Shllokkk/fix-bom-cost-allocation-per-type
fix: coerce cost_allocation_per to float in BOM cost allocation
2026-09-11 14:16:57 +05:30
Mihir Kandoi
142976a829 test(accounts): expect a naming rule counter per resolved prefix (#59000) 2026-09-11 08:12:52 +00:00
Diptanil Saha
1dd0b7dc9e fix(regional)!: remove irs_1099_print whitelisted method (#58976) 2026-09-11 09:40:22 +05:30
Diptanil Saha
b481083ff0 fix(accounts): add permission checks on invoice_discounting.get_invoices (#58975) 2026-09-11 09:39:38 +05:30
Raffael Meyer
be8208e7cb fix(stock): recalculate billing status of returned delivery notes (#58953) 2026-09-10 19:56:28 +02:00
Mihir Kandoi
4671d1a665 fix(stock): apply company restriction to Item Price (#58948) 2026-09-10 20:51:48 +05:30
Mihir Kandoi
5f216c5d55 fix(selling): exclude fully billed orders from the invoice picker (#58966) 2026-09-10 20:50:11 +05:30
Shllokkk
4d95a240bb chore: resolve merge conflicts 2026-09-10 19:07:25 +05:30
Diptanil Saha
86821ac6ee fix(accounts): add permission checks on execute_repost_payment_ledger (#58980) 2026-09-10 13:33:38 +00:00
Shllokkk
bc2fa03730 fix: coerce cost_allocation_per to float in BOM cost allocation 2026-09-10 18:53:39 +05:30
Mihir Kandoi
e6b8e90ad9 fix(stock): do not rebuild the Warehouse tree on an account map miss (#58956) 2026-09-10 18:15:28 +05:30
Pandiyan P
d82c35aae9 fix: avoid TypeError on new Work Order when company is not set (#58969)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-10 12:33:06 +00:00
Sudharsanan Ashok
b1e99a70ac fix(mrp): align monthly bucket labels with demand dates (#58963) 2026-09-10 12:13:44 +00:00
Mihir Kandoi
d5e63b8a9e fix(subcontracting): cannot select Against Finished Good on customer receipt (#58949) 2026-09-09 18:42:31 +05:30
Pandiyan P
3be0c7801a fix(manufacturing): handle empty raw materials in workstation (#58927) 2026-09-09 18:05:03 +05:30
Pandiyan P
33a066d568 fix(manufacturing): handle empty bom cost allocation (#58939) 2026-09-09 18:04:10 +05:30
ruthra kumar
cf6aeddc29 Merge pull request #58732 from ruthra-kumar/rewriting_process_pcv_on_map_reduce
refactor: rewriting process pcv on mapreduce
2026-09-09 17:28:04 +05:30
ruthra kumar
3f422f8e0d refactor: use qb for estimation and include correction factor 2026-09-09 17:08:40 +05:30
Afsal Syed
f130c64530 fix(stock): set item_name to fetch if empty in packing slip item (#58925) 2026-09-09 11:36:56 +00:00
ruthra kumar
681bd2734f refactor: post ledger entries once mapreduce is complete 2026-09-09 16:23:32 +05:30
ruthra kumar
31205c4114 refactor: dashboard for pcv 2026-09-09 16:23:32 +05:30
ruthra kumar
503a80f2c9 refactor: dynamic link to mapreduce and clean on cancel and trash 2026-09-09 16:23:32 +05:30
ruthra kumar
3a8bd852d5 refactor: rebuild pcv on mapreduce (parallelization) 2026-09-09 16:23:31 +05:30
Diptanil Saha
a8ec43bcf8 fix(selling): add email permission check on send_emails (#58935) 2026-09-09 15:37:55 +05:30
Diptanil Saha
e2b2940452 fix(accounts): add permission checks on send_proforma_email (#58933) 2026-09-09 09:53:29 +00:00
Shllokkk
3761eb8cbe fix: % Amount Billed exceeds 100% on fully rejected Purchase Receipts (#58885)
* fix: include rejected qty in Purchase Receipt billing base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: per billed stays 100% for fully rejected receipt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:58:18 +05:30
Mihir Kandoi
fd492100b0 fix(stock): ignore disabled price lists on transactions (#58926) 2026-09-09 09:15:44 +00:00
Mihir Kandoi
28bd1332de fix(accounts): stop counting received qty on non-stock invoice returns (#58924)
* fix(accounts): skip received/rejected qty checks on non-stock return

A Purchase Invoice without Update Stock writes no Stock Ledger Entry, so
received_qty and rejected_qty on its rows move no stock and bill no
amount. The server never derives or validates them either:
validate_accepted_rejected_qty only runs when update_stock is set.

validate_quantity still counted both columns against the source invoice.
Whatever value the form last wrote to the read-only received_qty was
tallied as returned, so a partial return that lowered qty locked out the
rest of the invoice with StockOverReturnError.

Restrict the two columns to documents that actually carry an
accepted/rejected split: Purchase Receipt, Subcontracting Receipt, and a
Purchase Invoice with Update Stock. qty stays validated in every case, so
the billed quantity is still capped at what the source invoice billed.

* test(accounts): cover partial returns of a non-stock invoice

Fails before the previous commit with StockOverReturnError on the second
return, because the stale received_qty carried by the first return is
tallied as a full return of the invoice.

* refactor(accounts): pass frm into the Purchase Invoice hide_fields

hide_fields took a doc but reached for cur_frm to get the grid and to
refresh, so it only worked on whichever form happened to be current. Take
frm instead: all three callers already have one.

frm.toggle_display replaces the hide_field / unhide_field globals, which
resolve the docfield through cur_frm the same way. var becomes let/const.

No change in behaviour.

* fix(accounts): hide stock columns without Update Stock

Received Qty, Rejected Qty and the warehouse section were shown on any
return, including one that updates no stock. received_qty is read-only
there and rejected_qty moves neither stock nor billed amount, so the grid
offered values the user could not correct and the form could not keep in
step with qty.

Show the group only when the invoice updates stock, matching an ordinary
Purchase Invoice. Nothing on these rows needs a warehouse either:
validate_warehouse only checks the warehouses that are set.
2026-09-09 08:55:35 +00:00
Pandiyan P
4b23cee2ea fix(stock): remove empty delivery stops after mapping (#58896)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:24:10 +00:00
Khushi Rawat
bdf124ab06 Merge pull request #58909 from khushi8112/fix/posting-date-confirmation
fix: read posting date confirmation setting from boot data
2026-09-09 12:50:41 +05:30
Pandiyan P
b2bdeaa672 fix: validate shelf life for automatically created expiring batches (#58911)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-09 07:07:03 +00:00
khushi8112
bcade8f0ba fix: refresh posting date confirmation setting 2026-09-09 12:36:28 +05:30
Pandiyan P
4c32acf300 fix: reset price list fallback when changing parties (#58893) 2026-09-09 12:26:05 +05:30
khushi8112
db9e93306a fix: read posting date confirmation setting from boot data 2026-09-09 11:52:42 +05:30
Khushi Rawat
e93ca84398 Merge pull request #58227 from aerele/fix/dunning-interest-lost-after-payment
fix: keep a dunning claimable until its interest is paid too
2026-09-09 10:47:07 +05:30
Pandiyan P
afd93cf867 fix(stock): guard inventory dimension lookup without reference document (#58889)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:42:40 +05:30
Vishnu Priya Baskaran
51fb261b6b fix: calculate holiday list totals for half days (#58792)
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:38:23 +05:30
Pandiyan P
f8c2f3440b fix(accounts): reject disabled price lists in buying and selling (#58891) 2026-09-09 06:57:48 +05:30
Diptanil Saha
991cea5ae2 fix(accounts): add permission checks to Chart of Accounts Importer methods (#58892) 2026-09-09 01:14:27 +05:30
Diptanil Saha
11d2847d3e fix(support): bind issue split to its own timeline (#58890) 2026-09-09 00:26:39 +05:30
Krishna Pramod Shirsath
e0d6d797d7 fix(stock): preserve rates for unsaved mapped rows (#58852)
* fix(stock): preserve rates for unsaved mapped rows

* test(stock): cover mapped delivery note item rates
2026-09-08 18:45:02 +05:30
pandiyan
d5a9d158f9 fix: keep a dunning claimable until its interest is paid too
a dunning was resolved as soon as the invoiced sum was settled, because the
status was derived from the invoice outstanding alone. paying an invoice
without the interest and fee therefore closed the dunning and lost the
interest: a fresh dunning finds nothing overdue to charge it on.

the dunning amount is never a receivable, it only reaches the ledger as a
negative deduction on a payment entry made from the dunning. link that row
to the dunning so what has been collected is known, and resolve a dunning
only once the invoiced sum and the dunning amount are both paid. a dunning
resolved by hand keeps its status, so waiving the interest stays possible.

the deduction is a company currency field, so book and measure the dunning
amount through base_dunning_amount instead of the transaction currency one.

an interest-only payment leaves every invoice outstanding untouched, so
update the linked dunnings from the payment entry itself instead of relying
on the outstanding amount to change. such a payment also has to be built
from what is left to collect, not from the totals the dunning was raised
with, which are stale by then.
2026-09-08 17:49:36 +05:30
pandiyan
fa89552d10 test: dunning must stay unpaid when only the invoiced sum is paid
paying a dunned invoice without its interest and fee marks the dunning
resolved, and the interest can no longer be collected.
2026-09-08 17:48:15 +05:30
Krishna Pramod Shirsath
cee9f4949a fix: show cost center for unmatched payments in receivables (#58453) 2026-09-08 16:52:53 +05:30
Vishnu Priya Baskaran
218e7927ff fix: fetch accounts by company (#58523) 2026-09-08 16:47:46 +05:30
Pandiyan P
36a4dfe797 fix(accounts): reject same-account internal transfers (#58529) 2026-09-08 16:29:20 +05:30
Sudharsanan Ashok
e825bb2f74 fix(accounts): round journal entry totals to field precision (#58629) 2026-09-08 16:26:04 +05:30
Pandiyan P
f864333afa fix(stock): recalculate delivery note billing after return (#58869) 2026-09-08 10:51:55 +00:00
Krishna Pramod Shirsath
c412310eb5 fix: use account currency in Bank Reconciliation Statement print (#58692) 2026-09-08 16:14:09 +05:30
Sudharsanan Ashok
1728d1b0f5 fix(stock): distribute additional costs when incoming items have no value (#58842)
* fix(stock): distribute additional costs when incoming items have no value

when every incoming row has zero basic amount, for example a raw material
purchased at zero rate, distribute_additional_costs returned early and left
additional_cost at 0 on the finished item. the charges were never capitalised
into valuation and the balancing debit stayed in stock adjustment instead of
reaching stock in hand.

the gl composer had its own quantity fallback for the same case, but it divided
by the qty of every row rather than the incoming ones, so a manufacture entry
booked only a fraction of the cost to the expense account, and it apportioned
by qty while valuation apportions by stock qty, which split rows of differing
conversion factors two different ways.

both sides now take the rows, the basis and its total from a single
get_additional_cost_allocation, falling back to transfer_qty so valuation and gl
cannot disagree. the basis is unchanged whenever the incoming rows carry value.

* test(stock): cover additional cost distribution for zero valued items

adds qty based distribution cases for manufacture and material receipt, a
manufacture entry asserting the whole cost reaches its expense account, and a
conversion factor case asserting the gl split matches the valuation split.

updates test_total_basic_amount_zero, which asserted the cost landing in stock
adjustment rather than being capitalised.
2026-09-08 16:13:12 +05:30
Lakshit Jain
79fc039092 Merge pull request #58682 from ljain112/fix-tds-subcontracting
fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping
2026-09-08 14:53:58 +05:30
Vishnu Priya Baskaran
467f54162f fix(gross-profit): include item_name in export (#58631) 2026-09-08 14:43:42 +05:30
Diptanil Saha
f60c349794 fix(buying): restrict linked material requests to permitted documents (#58855) 2026-09-08 14:23:03 +05:30
Pandiyan P
cadc0ca86d fix: persist rendered terms on transaction save (#58858) 2026-09-08 13:28:30 +05:30
Sudharsanan Ashok
2f572b1624 fix(stock): distribute additional costs when incoming items have no value (#58841) 2026-09-08 13:27:41 +05:30
ljain112
13031d6d5d fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping 2026-09-02 13:25:23 +05:30
118 changed files with 4184 additions and 1683 deletions

View File

@@ -222,6 +222,13 @@ class AccountsSettings(Document):
set_allow_on_submit_for_dimension_fields(doctypes)
@frappe.whitelist(methods=["POST"])
def get_posting_date_confirmation() -> int:
return cint(
frappe.db.get_single_value("Accounts Settings", "confirm_before_resetting_posting_date", cache=False)
)
def toggle_accounting_dimension_sections(hide):
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
for doctype in accounting_dimension_doctypes:

View File

@@ -1,9 +1,15 @@
import frappe
from erpnext.accounts.doctype.accounts_settings.accounts_settings import get_posting_date_confirmation
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsSettings(ERPNextTestSuite):
def test_posting_date_confirmation_uses_current_setting(self):
for enabled in (0, 1, 0):
frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", enabled)
self.assertEqual(get_posting_date_confirmation(), enabled)
def test_stale_days(self):
cur_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
cur_settings.allow_stale = 0

View File

@@ -8,6 +8,7 @@ from functools import reduce
import frappe
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.desk.form.linked_with import get_linked_fields
from frappe.model.document import Document
from frappe.utils import cint, cstr
@@ -58,6 +59,8 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
@@ -110,7 +113,10 @@ def import_coa(file_name: str, company: str):
def get_file(file_name):
file_doc = frappe.get_doc("File", {"file_url": file_name})
file_doc = find_file_by_url(file_name)
if not file_doc:
raise frappe.PermissionError
parts = file_doc.get_extension()
extension = parts[1]
extension = extension.lstrip(".")
@@ -179,6 +185,8 @@ def get_coa(
):
"""called by tree view (to fetch node's children)"""
frappe.has_permission("Chart of Accounts Importer", throw=True)
file_doc, extension = get_file(file_name)
parent = None if parent == _("All Accounts") else parent
@@ -326,6 +334,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type: str, template_type: str, company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":
@@ -378,7 +388,6 @@ def get_sample_template(writer, company):
return writer
@frappe.whitelist()
def validate_accounts(file_doc: Document, extension: str):
if extension == "csv":
accounts = generate_data_from_csv(file_doc, as_dict=True)

View File

@@ -17,7 +17,8 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import getdate
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext.controllers.accounts_controller import AccountsController
@@ -147,6 +148,31 @@ class Dunning(AccountsController):
)
row.dunning_level = len(past_dunnings) + 1
def get_unpaid_base_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in company currency."""
if not self.base_dunning_amount:
return 0.0
return flt(
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
self.precision("base_dunning_amount"),
)
def get_unpaid_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
return flt(
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
self.precision("dunning_amount"),
)
def get_unpaid_overdue_payments(self):
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
return [
(row, outstanding)
for row in self.overdue_payments
if (outstanding := get_current_outstanding(row)) > 0
]
def on_cancel(self):
super().on_cancel()
self.ignore_linked_doctypes = [
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -259,11 +286,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
if has_outstanding:
break
new_status = "Resolved" if not has_outstanding else "Unresolved"
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
if dunning.status != new_status:
dunning.status = new_status
dunning.save()
def update_dunnings_linked_to_payment(payment_entry):
"""Refresh dunnings whose interest and fee are settled by this payment."""
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
for name in dunnings:
dunning = frappe.get_doc("Dunning", name)
if dunning.docstatus != 1:
continue
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
# resolving by hand waives the interest, only an invoice that is owed again reopens it
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
return
if dunning.status != new_status:
dunning.db_set("status", new_status, notify=True)
def get_paid_dunning_amount(dunning: str) -> float:
"""Interest and fee collected for this dunning, in company currency."""
deduction = frappe.qb.DocType("Payment Entry Deduction")
payment_entry = frappe.qb.DocType("Payment Entry")
paid = (
frappe.qb.from_(deduction)
.join(payment_entry)
.on(payment_entry.name == deduction.parent)
.select(Sum(deduction.amount))
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
).run()
# the dunning amount is booked as a negative deduction, against the income account
return -flt(paid[0][0]) if paid else 0.0
def get_current_outstanding(overdue_payment) -> float:
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
invoice = frappe.db.get_value(
"Sales Invoice",
overdue_payment.sales_invoice,
["outstanding_amount", "currency", "party_account_currency"],
as_dict=True,
)
schedule_outstanding = (
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
if overdue_payment.payment_schedule
else flt(overdue_payment.outstanding)
)
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
return 0.0
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
if invoice.currency == invoice.party_account_currency:
outstanding = min(outstanding, flt(invoice.outstanding_amount))
return outstanding
def get_linked_dunnings_as_per_state(sales_invoice, state):

View File

@@ -55,6 +55,125 @@ class TestDunning(ERPNextTestSuite):
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
"""
Regression for #58220: paying the invoice without the interest and fee must not
resolve the dunning, the interest is still owed and has to stay claimable.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "4", nowdate()
pe.insert()
pe.submit()
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the interest and fee can still be collected on their own
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "5", nowdate()
self.assertEqual(pe.references, [])
self.assertEqual(round(pe.paid_amount, 2), 10.41)
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
# cancelling the interest payment makes the dunning claimable again
pe.cancel()
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
"""
The payment collecting the interest links back to the dunning, which must not stand in
the way of cancelling it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "6", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
dunning.cancel()
self.assertEqual(dunning.docstatus, 2)
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
"""
Resolving a dunning by hand waives its interest, so a later payment of the invoice
must not reopen it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
# what the "Resolve" button does
dunning.reload()
dunning.status = "Resolved"
dunning.save()
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "7", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
"""
The interest and fee are collected as a Payment Entry deduction, a company currency
field, so what is left to collect has to be measured in the same currency.
"""
si = create_sales_invoice(
posting_date=add_days(today(), -15),
currency="USD",
conversion_rate=50,
rate=100,
debit_to="Debtors - _TC",
)
dunning = create_dunning_from_sales_invoice(si.name)
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
dunning.dunning_type = dunning_type.name
dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee
dunning.income_account = dunning_type.income_account
dunning.cost_center = dunning_type.cost_center
dunning.save()
self.assertEqual(dunning.currency, "USD")
self.assertEqual(dunning.conversion_rate, 50)
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
# nothing collected yet, in either currency
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the deduction booking the interest is in company currency
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
def test_fetch_overdue_payments(self):
"""
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.

View File

@@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", {
],
primary_action: function () {
var data = d.get_values();
data.company = frm.doc.company;
frappe.call({
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_import": 1,
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
"creation": "2019-03-07 12:01:56.296952",
@@ -170,7 +171,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:09:52.746196",
"modified": "2026-09-09 17:04:59.512294",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Invoice Discounting",
@@ -187,14 +188,15 @@
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters: str | dict):
filters = frappe._dict(frappe.parse_json(filters))
if not filters.get("company"):
frappe.throw(_("Please set company on the Document before requesting for invoices."))
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
frappe.has_permission("Invoice Discounting", throw=True)
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")

View File

@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
total_credit += flt(row.credit, precision("credit", row));
});
frm.doc.total_debit = total_debit;
frm.doc.total_credit = total_credit;
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
},

View File

@@ -674,12 +674,14 @@ class JournalEntry(AccountsController):
if d.debit and d.credit:
frappe.throw(_("You cannot credit and debit same account at the same time"))
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
self.total_debit = flt(
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
)
self.total_credit = flt(
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
)
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
self.total_credit, self.precision("total_credit")
)
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
def validate_multi_currency(self):
alternate_currency = []

View File

@@ -461,6 +461,59 @@ class TestJournalEntry(ERPNextTestSuite):
self.check_gl_entries()
def make_jv_with_fractional_totals(self):
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
jv = frappe.new_doc("Journal Entry")
jv.posting_date = nowdate()
jv.company = "_Test Company"
jv.voucher_type = "Journal Entry"
jv.remark = "test"
for amount in (0.10, 0.20):
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": amount,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 0.30,
},
)
jv.insert()
return jv
def test_totals_are_rounded_to_precision(self):
jv = self.make_jv_with_fractional_totals()
jv.submit()
stored = frappe.db.get_value(
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
)
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
self.assertEqual(jv.total_debit, stored.total_debit)
self.assertEqual(jv.total_credit, stored.total_credit)
self.assertEqual(jv.difference, stored.difference)
def test_update_after_submit_with_fractional_totals(self):
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
jv = self.make_jv_with_fractional_totals()
jv.submit()
jv.pay_to_recd_from = "_Test Supplier"
jv.save()
self.assertEqual(jv.docstatus, 1)
self.assertEqual(
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
)
def test_jv_account_and_party_balance_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.utils import get_balance_on

View File

@@ -46,23 +46,27 @@ frappe.ui.form.on("Payment Entry", {
},
setup: function (frm) {
frm.set_query("paid_from", function () {
frm.set_query("paid_from", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
filters.name = ["!=", doc.paid_to];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});
@@ -106,21 +110,25 @@ frappe.ui.form.on("Payment Entry", {
}
});
frm.set_query("paid_to", function () {
frm.set_query("paid_to", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
filters.name = ["!=", doc.paid_from];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});

View File

@@ -176,6 +176,7 @@ class PaymentEntry(AccountsController):
self.set_liability_account()
self.set_missing_ref_details(force=True)
self.validate_payment_type()
self.validate_internal_transfer_accounts()
self.validate_party_details()
self.set_exchange_rate()
self.validate_mandatory()
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
def update_linked_dunnings(self):
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
update_dunnings_linked_to_payment(self)
def validate_for_repost(self):
validate_docs_for_voucher_types(["Payment Entry"])
validate_docs_for_deferred_accounting([self.name], [])
@@ -315,6 +322,7 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.delink_advance_entry_references()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
@@ -627,6 +635,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
def validate_internal_transfer_accounts(self):
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
def validate_party_details(self):
if self.party and not frappe.db.exists(self.party_type, self.party):
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
@@ -2725,7 +2737,7 @@ def get_payment_entry(
pe.append("references", reference)
else:
if dt == "Dunning":
for overdue_payment in doc.overdue_payments:
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
pe.append(
"references",
{
@@ -2733,21 +2745,23 @@ def get_payment_entry(
"reference_name": overdue_payment.sales_invoice,
"payment_term": overdue_payment.payment_term,
"due_date": overdue_payment.due_date,
"total_amount": overdue_payment.outstanding,
"outstanding_amount": overdue_payment.outstanding,
"allocated_amount": overdue_payment.outstanding,
"total_amount": outstanding,
"outstanding_amount": outstanding,
"allocated_amount": outstanding,
},
)
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * doc.dunning_amount,
"description": _("Interest and/or dunning fee"),
},
)
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * unpaid_dunning_amount,
"description": _("Interest and/or dunning fee"),
"dunning": doc.name,
},
)
else:
pe.append(
"references",
@@ -3040,8 +3054,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
grand_total = doc.rounded_total or doc.grand_total
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
# only what is left to collect, the totals on the dunning are the ones it was raised with
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
grand_total += doc.get_unpaid_dunning_amount()
outstanding_amount = grand_total
else:
if party_account_currency == doc.company_currency:
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))

View File

@@ -782,6 +782,23 @@ class TestPaymentEntry(ERPNextTestSuite):
self.validate_gl_entries(pe.name, expected_gle)
def test_internal_transfer_rejects_same_account(self):
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.received_amount = 100
pe.reference_no = "same-account-transfer"
pe.reference_date = nowdate()
self.assertRaisesRegex(
frappe.ValidationError,
"Paid From and Paid To accounts must be different",
pe.insert,
)
def test_bank_charges_deduction(self):
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",

View File

@@ -10,7 +10,8 @@
"amount",
"column_break_2",
"is_exchange_gain_loss",
"description"
"description",
"dunning"
],
"fields": [
{
@@ -55,12 +56,21 @@
"fieldtype": "Check",
"label": "System Generated",
"read_only": 1
},
{
"fieldname": "dunning",
"fieldtype": "Link",
"label": "Dunning",
"no_copy": 1,
"options": "Dunning",
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-11 14:26:11.312950",
"modified": "2026-08-17 11:20:35.482913",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",

View File

@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
amount: DF.Currency
cost_center: DF.Link
description: DF.SmallText | None
dunning: DF.Link | None
is_exchange_gain_loss: DF.Check
parent: DF.Data
parentfield: DF.Data

View File

@@ -1041,40 +1041,6 @@ class TestPaymentRequestV2Gateway(ERPNextTestSuite):
mock_payments.utils = mock_utils
return {"payments": mock_payments, "payments.utils": mock_utils}, mock_utils
def test_is_v2_gateway_returns_false_for_none(self):
"""_is_v2_gateway returns False for None input."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
# Mock returns True, but is_v2_gateway(None) in payments.utils returns False
modules, mock_utils = self._mock_payments_modules(False)
with patch.dict(sys.modules, modules):
result = _is_v2_gateway(None)
self.assertFalse(result)
mock_utils.is_v2_gateway.assert_called_once_with(None)
def test_is_v2_gateway_returns_false_for_empty_string(self):
"""_is_v2_gateway returns False for empty string input."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
modules, mock_utils = self._mock_payments_modules(False)
with patch.dict(sys.modules, modules):
result = _is_v2_gateway("")
self.assertFalse(result)
mock_utils.is_v2_gateway.assert_called_once_with("")
def test_is_v2_gateway_returns_false_for_nonexistent_gateway(self):
"""_is_v2_gateway returns False for nonexistent gateway."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway
modules, mock_utils = self._mock_payments_modules(False)
with patch.dict(sys.modules, modules):
result = _is_v2_gateway("NonExistentGateway12345")
self.assertFalse(result)
mock_utils.is_v2_gateway.assert_called_once_with("NonExistentGateway12345")
def test_is_v2_gateway_delegates_to_payments_util(self):
"""_is_v2_gateway delegates to payments.utils.is_v2_gateway."""
from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway

View File

@@ -5,7 +5,7 @@ frappe.ui.form.on("Period Closing Voucher", {
onload: function (frm) {
if (!frm.doc.transaction_date) frm.doc.transaction_date = frappe.datetime.obj_to_str(new Date());
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher"];
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher", "MapReduce Job"];
},
setup: function (frm) {

View File

@@ -5,9 +5,20 @@
import copy
import frappe
from frappe import _
from frappe.query_builder.functions import Max, Sum
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
from frappe import _, qb
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Count, Max, Min, Sum
from frappe.utils import (
add_days,
ceil,
cint,
flt,
fmt_money,
formatdate,
get_datetime,
get_link_to_form,
getdate,
)
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
@@ -265,8 +276,17 @@ class PeriodClosingVoucher(AccountsController):
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.make_gl_entries()
else:
ppcv = frappe.get_doc({"doctype": "Process Period Closing Voucher", "parent_pcv": self.name})
ppcv.save().submit()
from frappe.utils.background_jobs import mapreduce
data = self.get_data_for_mapreduce()
mapreduce(
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
data,
self.doctype,
self.name,
)
def on_cancel(self):
self.ignore_linked_doctypes = (
@@ -275,11 +295,16 @@ class PeriodClosingVoucher(AccountsController):
"Payment Ledger Entry",
"Account Closing Balance",
"Process Period Closing Voucher",
"MapReduce Job",
)
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"):
from frappe.utils.background_jobs import cancel_mapreduce_job
cancel_mapreduce_job(self.doctype, self.name)
self.cancel_process_pcv_docs()
self.db_set("gle_processing_status", "In Progress")
@@ -292,6 +317,11 @@ class PeriodClosingVoucher(AccountsController):
def on_trash(self):
super().on_trash()
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
from frappe.utils.background_jobs import remove_mapreduce_job
remove_mapreduce_job(self.doctype, self.name)
ppcvs = frappe.db.get_all(
"Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": ["in", [1, 2]]}
)
@@ -594,6 +624,135 @@ class PeriodClosingVoucher(AccountsController):
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
)
def get_data_for_mapreduce(self):
return self.generate_tasks_for_normal_balance() + self.generate_tasks_for_opening_balance()
def get_period_range_for_tasks(self, start_date, end_date, step_size, report_type, balance_type):
start_date = getdate(start_date)
end_date = getdate(end_date)
# split period into date ranges
curr_date = getdate(start_date)
date_splits = []
while True:
next_date = getdate(add_days(curr_date, step_size))
if next_date < end_date:
date_splits.append(
{
"from_date": str(curr_date),
"to_date": str(next_date),
"pcv": self.name,
"report_type": report_type,
"balance_type": balance_type,
}
)
curr_date = getdate(add_days(next_date, 1))
else:
date_splits.append(
{
"from_date": str(curr_date),
"to_date": str(end_date),
"pcv": self.name,
"report_type": report_type,
"balance_type": balance_type,
}
)
break
return date_splits
def generate_tasks_for_normal_balance(self):
# estimation can be wrong by a factor of 2
gl = qb.DocType("GL Entry")
raw_query = (
qb.from_(gl)
.select(Count(gl.star))
.where(
gl.is_cancelled.eq(0) & gl.posting_date.between(self.period_start_date, self.period_end_date)
)
.get_sql()
)
# estimation can be wrong by a factor of 2
correction_factor = 2
if frappe.db.db_type == "postgres":
analyzer = frappe.json.loads(
(
frappe.db.sql(
f"explain (format json) {raw_query}",
)
)[0][0]
)
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
else:
estimated_count = (
cint(
frappe.db.sql(
f"explain {raw_query}",
as_dict=True,
)[0].rows
)
* correction_factor
)
job_count = (
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
) # conservative chunk size
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
return self.get_period_range_for_tasks(
self.period_start_date, self.period_end_date, step_size, "Balance Sheet", "Normal Balance"
) + self.get_period_range_for_tasks(
self.period_start_date, self.period_end_date, step_size, "Profit and Loss", "Normal Balance"
)
def generate_tasks_for_opening_balance(self):
tasks = []
if self.is_first_period_closing_voucher():
gl = qb.DocType("GL Entry")
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
raw_query = (
qb.from_(gl)
.select(Count(gl.star))
.where(gl.is_cancelled.eq(0) & gl.is_opening.eq("Yes") & gl.posting_date.between(min, max))
.get_sql()
)
# estimation can be wrong by a factor of 2
correction_factor = 2
if frappe.db.db_type == "postgres":
analyzer = frappe.json.loads(
(
frappe.db.sql(
f"explain (format json) {raw_query}",
)
)[0][0]
)
estimated_count = analyzer[0].get("Plan").get("Plans")[0].get("Plan Rows") * correction_factor
else:
estimated_count = (
cint(
frappe.db.sql(
f"explain {raw_query};",
as_dict=True,
)[0].rows
)
* correction_factor
)
job_count = (
1 if estimated_count / 2000000 < 1 else ceil(estimated_count / 2000000)
) # conservative chunk size
days = (getdate(self.period_end_date) - getdate(self.period_start_date)).days
step_size = 1 if days / job_count < 1 else ceil(days / job_count)
tasks = self.get_period_range_for_tasks(min, max, step_size, "Balance Sheet", "Opening Balance")
return tasks
def process_gl_and_closing_entries(doc):
from erpnext.accounts.general_ledger import make_gl_entries
@@ -673,3 +832,119 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
order_by="period_end_date desc",
)
return prev_closed_period_end_date
def mapper(val):
start_date = val.from_date
end_date = val.to_date
pcv = val.pcv
report_type = val.report_type
balance_type = val.balance_type
company = frappe.db.get_value("Period Closing Voucher", pcv, "company")
dimensions = get_dimensions()
accounts = frappe.db.get_all(
"Account", filters={"company": company, "report_type": report_type}, pluck="name"
)
gle = qb.DocType("GL Entry")
query = qb.from_(gle).select(gle.account)
for dim in dimensions:
query = query.select(gle[dim])
query = query.select(
Sum(gle.debit).as_("debit"),
Sum(gle.credit).as_("credit"),
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
Max(gle.account_currency).as_("account_currency"),
ConstantColumn(balance_type).as_("balance_type"),
ConstantColumn(report_type).as_("report_type"),
).where(
(gle.company.eq(company))
& (gle.is_cancelled.eq(0))
& (gle.posting_date.between(start_date, end_date))
& (gle.account.isin(accounts))
)
if balance_type == "Opening Balance":
query = query.where(gle.is_opening.eq("Yes"))
else:
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
query = query.where(gle.is_opening.eq("No"))
query = query.groupby(gle.account)
for dim in dimensions:
query = query.groupby(gle[dim])
res = query.run(as_dict=True)
return res
def reducer(final, partial_res):
if final is None:
final = []
if partial_res:
final.extend([frappe._dict(x) for x in partial_res])
return final
def get_dimensions():
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
default_dimensions = ["cost_center", "finance_book", "project"]
dimensions = default_dimensions + get_accounting_dimensions()
return dimensions
def summarize_and_post_ledger(result, ref_dt, ref_dn):
pcv = frappe.get_doc(ref_dt, ref_dn)
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
build_dimension_wise_balance_dict,
get_bs_closing_entries,
get_closing_account_closing_entry,
get_gle_for_closing_account,
get_gle_for_pl_account,
get_p_l_closing_entries,
)
result = [frappe._dict(x) for x in result]
# generate and post closing entries for P&L accounts
pl_entries = [x for x in result if x.report_type == "Profit and Loss"]
pl_dimension_wise_acc_balance = build_dimension_wise_balance_dict(pl_entries)
# build gl map
pl_accounts_reverse_gle = []
closing_account_gle = []
for dimensions, account_balances in pl_dimension_wise_acc_balance.items():
for acc, balances in account_balances.items():
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
if balance_in_company_currency:
pl_accounts_reverse_gle.append(get_gle_for_pl_account(pcv, acc, balances, dimensions))
closing_account_gle.append(get_gle_for_closing_account(pcv, account_balances["balances"], dimensions))
gl_entries = pl_accounts_reverse_gle + closing_account_gle
if gl_entries:
from erpnext.accounts.general_ledger import make_gl_entries
make_gl_entries(gl_entries, merge_entries=False)
# generate and post account closing balance for balance sheet accounts
bs_entries = [x for x in result if x.report_type == "Balance Sheet"]
bs_dimension_wise_acc_balance = build_dimension_wise_balance_dict(bs_entries)
pl_closing_entries = get_p_l_closing_entries(pl_accounts_reverse_gle, pcv)
bs_closing_entries = get_bs_closing_entries(bs_dimension_wise_acc_balance, pcv)
closing_entries_for_closing_account = get_closing_account_closing_entry(closing_account_gle, pcv)
closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")

View File

@@ -0,0 +1,8 @@
from frappe import _
def get_data():
return {
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
}

View File

@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
const me = this;
super.refresh();
hide_fields(this.frm.doc);
hide_fields(this.frm);
// Show / Hide button
this.show_general_ledger();
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
@@ -418,7 +418,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
is_paid() {
hide_fields(this.frm.doc);
hide_fields(this.frm);
if (cint(this.frm.doc.is_paid)) {
this.frm.set_value("allocate_advances_automatically", 0);
this.frm.set_value("payment_terms_template", "");
@@ -482,28 +482,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
// Hide Fields
// ------------
function hide_fields(doc) {
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
function hide_fields(frm) {
const doc = frm.doc;
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
if (cint(doc.is_paid) == 1) {
hide_field(parent_fields);
frm.toggle_display(parent_fields, false);
} else {
for (var i in parent_fields) {
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
if (!docfield.hidden) unhide_field(parent_fields[i]);
for (const fieldname of parent_fields) {
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
if (!docfield.hidden) frm.toggle_display(fieldname, true);
}
}
var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
if (cur_frm.fields_dict["items"]) {
cur_frm.fields_dict["items"].grid.set_column_disp(
item_fields_stock,
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
);
if (frm.fields_dict["items"]) {
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
}
cur_frm.refresh_fields();
frm.refresh_fields();
}
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
@@ -712,7 +710,7 @@ frappe.ui.form.on("Purchase Invoice", {
},
update_stock: function (frm) {
hide_fields(frm.doc);
hide_fields(frm);
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
},

View File

@@ -3061,6 +3061,23 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save)
def test_partial_returns_ignore_received_qty_without_update_stock(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
invoice = make_purchase_invoice(qty=10, received_qty=10)
first_return = make_return_doc(invoice.doctype, invoice.name)
first_return.items[0].qty = -4
first_return.save().submit()
self.assertEqual(first_return.items[0].received_qty, -10)
second_return = make_return_doc(invoice.doctype, invoice.name)
second_return.items[0].qty = -6
second_return.save().submit()
self.assertEqual(second_return.docstatus, 1)
def test_apply_discount_on_grand_total(self):
"""
To test if after applying discount on grand total,

View File

@@ -123,6 +123,8 @@ class RepostPaymentLedger(Document):
def execute_repost_payment_ledger(docname: str):
"""Repost Payment Ledger Entries by background job."""
frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True)
job_name = "payment_ledger_repost_" + docname
frappe.enqueue(

View File

@@ -26,6 +26,7 @@ import erpnext
from erpnext import get_company_currency
from erpnext.accounts.utils import get_fiscal_year
from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
from erpnext.utilities.regional import temporary_flag
try:
@@ -394,12 +395,17 @@ def set_other_values(party_details, party, party_type):
def get_default_price_list(party):
"""Return default price list for party (Document object)"""
if party.get("default_price_list"):
return party.default_price_list
"""Return the first enabled default price list for party (Document object)"""
price_list = party.get("default_price_list")
if is_price_list_enabled(price_list):
return price_list
if party.doctype == "Customer":
return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
if party.doctype != "Customer":
return
price_list = frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
if is_price_list_enabled(price_list):
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
@@ -412,7 +418,7 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
if customer_price_list:
if is_price_list_enabled(customer_price_list):
price_list = customer_price_list
else:
pos_price_list = frappe.get_value("POS Profile", pos, "selling_price_list")
@@ -420,6 +426,9 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
else:
price_list = get_default_price_list(party) or given_price_list
if price_list and not is_price_list_enabled(price_list):
price_list = None
if price_list:
party_details.price_list_currency = frappe.db.get_value(
"Price List", price_list, "currency", cache=True

View File

@@ -171,6 +171,7 @@ class ReceivablePayableReport:
party_account=ple.account,
posting_date=ple.posting_date,
account_currency=ple.account_currency,
cost_center=ple.cost_center,
remarks=ple.remarks,
invoiced=0.0,
paid=0.0,

View File

@@ -1337,6 +1337,28 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
row = report[1][0]
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
def test_cost_center_on_payment_before_invoice(self):
filters = {
"company": self.company,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
}
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.posting_date = add_days(today(), 1)
si.due_date = si.posting_date
si.payment_schedule[0].due_date = si.posting_date
si.save().submit()
pe = self.create_payment_entry(si.name, do_not_submit=True)
pe.cost_center = self.cost_center
pe.save().submit()
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
self.assertEqual(row.cost_center, pe.cost_center)
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms

View File

@@ -28,16 +28,16 @@
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
{% } %}
</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } else { %}
<tr>
<td></td>
<td></td>
<td>{%= data[i]["payment_entry"] %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } %}
{% } %}

View File

@@ -114,6 +114,7 @@ def execute(filters=None):
filters={
"account_type": row["account_type"],
"is_group": 0,
"company": filters.company,
},
pluck="name",
)

View File

@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
columns[0]["fieldname"] = "sales_invoice"
columns[0]["options"] = "Item"
columns[0]["width"] = 300
# removing Item Code and Item Name columns
# removing the duplicate Item Code column and moving Item Name before Customer
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
del columns[4:6]
del columns[4]
columns.insert(1, columns.pop(4))
else:
del columns[5:7]
del columns[5]
columns.insert(1, columns.pop(5))
total_base_amount = 0
total_buying_amount = 0

View File

@@ -1,6 +1,6 @@
import frappe
from erpnext.accounts.party import get_default_price_list
from erpnext.accounts.party import get_default_price_list, set_price_list
from erpnext.tests.utils import ERPNextTestSuite
@@ -16,3 +16,46 @@ class PartyTestCase(ERPNextTestSuite):
customer.save()
price_list = get_default_price_list(customer)
assert price_list is None
def test_disabled_party_default_should_fall_back_to_given_price_list(self):
customer = self.create_customer(default_price_list=self.create_price_list(enabled=0))
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
set_price_list(party_details, customer, "Customer", given_price_list)
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_disabled_given_price_list_should_not_be_set(self):
customer = self.create_customer()
party_details = frappe._dict()
set_price_list(party_details, customer, "Customer", self.create_price_list(enabled=0))
self.assertIsNone(party_details.selling_price_list)
def create_price_list(self, enabled):
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": 1,
"enabled": enabled,
}
).insert(ignore_permissions=True)
return price_list.name
def create_customer(self, **values):
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": frappe.generate_hash(length=10),
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
customer.customer_group = None
customer.save()
return customer

View File

@@ -581,7 +581,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
var item_length = me.frm.doc.items.length;
while (i < item_length) {
var qty = me.frm.doc.items[i].qty;
(r.message[0] || []).forEach(function (d) {
(r.message || []).forEach(function (d) {
if (
d.qty > 0 &&
qty > 0 &&

View File

@@ -226,6 +226,7 @@ class PurchaseOrder(BuyingController):
self.doctype, self.supplier, self.company, self.inter_company_order_reference
)
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.set_missing_terms()
def set_has_unit_price_items(self):
"""

View File

@@ -0,0 +1,139 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import json
import frappe
import frappe.permissions
from erpnext.buying.utils import get_linked_material_requests
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.tests.utils import ERPNextTestSuite
def create_user_with_roles(email, *roles):
if frappe.db.exists("User", email):
user = frappe.get_doc("User", email)
else:
user = frappe.new_doc("User")
user.email = email
user.first_name = email.split("@", 1)[0]
user.insert(ignore_permissions=True)
user.set("roles", [])
for role in roles:
user.append("roles", {"role": role})
user.save(ignore_permissions=True)
# a user left without roles is downgraded to a Website User on save
frappe.db.set_value("User", email, "user_type", "System User")
return user
class TestGetLinkedMaterialRequests(ERPNextTestSuite):
def setUp(self):
self.material_request = make_material_request(item_code="_Test Item")
def test_permitted_role_can_fetch_linked_material_requests(self):
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_populated_result_is_a_flat_list_of_rows(self):
"""Both callers iterate the response directly, so it has to stay a flat list of rows
rather than a list of lists."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIsInstance(rows, list)
self.assertTrue(rows)
for row in rows:
self.assertNotIsInstance(row, list | tuple)
self.assertIsInstance(row, dict)
for fieldname in ("mr_name", "mr_item", "item_code", "qty"):
self.assertIn(fieldname, row)
def test_empty_result_is_a_flat_empty_list(self):
item_without_request = make_item("_Test Item Without Material Request").name
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests([item_without_request])
self.assertEqual(rows, [])
def test_a_single_item_code_is_treated_as_one_code(self):
"""A lone code must be read as one item code, not iterated character by character."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(json.dumps("_Test Item"))
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_items_that_are_not_item_codes_are_rejected(self):
"""Anything that is not a `str` or a `list` is already refused by the type annotation,
so these are the malformed inputs that reach the method."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
bad_inputs = (
"not json at all",
[{"item_code": "_Test Item"}],
[["_Test Item"]],
[None],
)
with self.set_user("test_buying_purchase_user@example.com"):
for bad_items in bad_inputs:
with self.subTest(items=bad_items):
self.assertRaises(frappe.ValidationError, get_linked_material_requests, bad_items)
def test_manufacturing_manager_can_fetch_linked_material_requests(self):
"""Manufacturing Manager holds write on Supplier Quotation and Request for Quotation,
both of which call this method, so it must hold Material Request read as well."""
create_user_with_roles("test_buying_mfg_manager@example.com", "Manufacturing Manager")
with self.set_user("test_buying_mfg_manager@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_unpermitted_role_cannot_fetch_linked_material_requests(self):
create_user_with_roles("test_buying_sales_user@example.com", "Sales User")
with self.set_user("test_buying_sales_user@example.com"):
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
def test_role_with_only_select_permission_cannot_fetch_linked_material_requests(self):
"""Material Request grants Delivery and Maintenance roles `select` and nothing else.
`select` is enough to list names, so the permitted set must be resolved through a
filter on the child table, which requires `read`."""
create_user_with_roles("test_buying_delivery_user@example.com", "Delivery User")
with self.set_user("test_buying_delivery_user@example.com"):
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
def test_results_are_restricted_by_user_permissions(self):
other_company_request = make_material_request(
item_code="_Test Item",
company="_Test Company 1",
warehouse="_Test Warehouse 2 - _TC1",
cost_center="Main - _TC1",
)
user = create_user_with_roles("test_buying_restricted_user@example.com", "Purchase User")
frappe.permissions.add_user_permission("Company", "_Test Company", user.name)
try:
with self.set_user(user.name):
mr_names = {row.mr_name for row in get_linked_material_requests(["_Test Item"])}
finally:
frappe.permissions.remove_user_permission("Company", "_Test Company", user.name)
self.assertIn(self.material_request.name, mr_names)
self.assertNotIn(other_company_request.name, mr_names)

View File

@@ -129,7 +129,33 @@ def get_linked_material_requests(items: str | list):
Retrieve Material Requests linked to a list of items.
"""
items = frappe.parse_json(items)
try:
items = frappe.parse_json(items)
except (TypeError, ValueError):
frappe.throw(_("Items must be a list of Item codes"))
if isinstance(items, str):
items = [items]
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
frappe.throw(_("Items must be a list of Item codes"))
permitted_material_requests = frappe.get_list(
"Material Request",
filters=[
["material_request_type", "=", "Purchase"],
["docstatus", "=", 1],
["status", "!=", "Stopped"],
["per_ordered", "<", 99.99],
["Material Request Item", "item_code", "in", items],
],
pluck="name",
distinct=True,
)
if not permitted_material_requests:
return []
mr_list = []
mr = frappe.qb.DocType("Material Request")
@@ -146,6 +172,7 @@ def get_linked_material_requests(items: str | list):
mr_item.item_code,
mr_item.name.as_("mr_item"),
)
.where(mr.name.isin(permitted_material_requests))
.where(mr_item.item_code == item)
.where(mr.material_request_type == "Purchase")
.where(mr.per_ordered < 99.99)

View File

@@ -258,6 +258,8 @@ class AccountsController(TransactionBase):
if self.get("_action") and self._action != "update_after_submit":
self.set_missing_values(for_validate=True)
self.validate_price_list()
if self.get("_action") == "submit":
self.remove_bundle_for_non_stock_invoices()
@@ -346,6 +348,28 @@ class AccountsController(TransactionBase):
self.set_default_letter_head()
self.validate_company_in_accounting_dimension()
def validate_price_list(self):
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
price_list = self.get(price_list_field)
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
return
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
if (
self.get("is_return")
and self.get("return_against")
and price_list
== frappe.db.get_value(
self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field
)
):
return
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
def set_default_letter_head(self):
if hasattr(self, "letter_head") and not self.letter_head:
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")

View File

@@ -194,7 +194,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock:
fields = ["qty"]
if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]:
tracks_accepted_rejected_split = doc.doctype in (
"Purchase Receipt",
"Subcontracting Receipt",
) or (doc.doctype == "Purchase Invoice" and doc.update_stock)
if tracks_accepted_rejected_split:
if not args.get("return_qty_from_rejected_warehouse"):
fields.extend(["received_qty", "rejected_qty"])
else:

View File

@@ -1161,7 +1161,11 @@ def get_fg_reference_names(
"Subcontracting Inward Order Item",
limit_start=start,
limit_page_length=page_len,
filters={"parent": filters.get("parent"), "item_code": ("like", f"%{txt}%"), "docstatus": 1},
filters={"parent": filters.get("parent"), "docstatus": 1},
or_filters=[
["name", "like", f"%{txt}%"],
["item_code", "like", f"%{txt}%"],
],
fields=["name", "item_code", "delivery_warehouse"],
as_list=True,
order_by="idx",

View File

@@ -2410,4 +2410,10 @@ class TestAccountsController(ERPNextTestSuite):
si.set_posting_time = 1
si.posting_date = "2026-01-01"
si.save()
self.assertEqual(si.name, "SI-01-2026-00001")
si = create_sales_invoice(do_not_save=True)
si.set_posting_time = 1
si.posting_date = "2026-01-15"
si.save()
self.assertEqual(si.name, "SI-01-2026-00002")

View File

@@ -330,12 +330,14 @@ permission_query_conditions = {
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
}
has_permission = {
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
}
has_website_permission = {

File diff suppressed because it is too large Load Diff

View File

@@ -1040,7 +1040,7 @@ frappe.tour["BOM"] = [
frappe.ui.form.on("BOM Secondary Item", {
valuation_type(frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (row.valuation_type !== "% of FG Cost") {
if (row.valuation_type !== "% of Component Cost") {
frappe.model.set_value(cdt, cdn, "cost_allocation_per", 0);
}
if (row.valuation_type === "Valuation Rate") {

View File

@@ -529,13 +529,14 @@ class BOM(WebsiteGenerator):
doc.set_status(save=True)
def set_fg_cost_allocation(self):
self.cost_allocation_per = flt(self.cost_allocation_per)
total_secondary_items_per = 0
own_cost = 0
for item in self.secondary_items:
if item.valuation_type in ("Valuation Rate", "Manual"):
item.cost_allocation_per = 0
own_cost += flt(item.cost)
total_secondary_items_per += item.cost_allocation_per
total_secondary_items_per += flt(item.cost_allocation_per)
if self.cost_allocation_per == 100 and total_secondary_items_per:
self.cost_allocation_per -= total_secondary_items_per
@@ -551,9 +552,9 @@ class BOM(WebsiteGenerator):
)
def validate_total_cost_allocation(self):
total_cost_allocation_per = self.cost_allocation_per
total_cost_allocation_per = flt(self.cost_allocation_per)
for item in self.secondary_items:
total_cost_allocation_per += item.cost_allocation_per
total_cost_allocation_per += flt(item.cost_allocation_per)
if total_cost_allocation_per != 100:
frappe.throw(_("Cost allocation between finished goods and secondary items should equal 100%"))
@@ -901,6 +902,19 @@ class BOM(WebsiteGenerator):
)
)
bom_items = {self.item, *items}
bom_items.update(d.item_code for d in self.get("secondary_items"))
bom_items.update(d.finished_good for d in self.get("operations") if d.finished_good)
if disabled_items := frappe.db.get_all(
"Item", filters={"item_code": ("in", list(bom_items)), "disabled": 1}, pluck="name"
):
frappe.throw(
_("Disabled Item {0} cannot be used in BOMs.").format(
", ".join(get_link_to_form("Item", item) for item in disabled_items)
)
)
def check_recursion(self):
"""Check whether recursion occurs in any bom"""
bom_list = self.traverse_tree()

View File

@@ -269,7 +269,7 @@ class BOMCostingService:
def calculate_secondary_items_costs(self, save=False):
"""Valuation Rate and Manual rows carry their own cost, deducted from the raw
material cost; the % of FG Cost rows split the remainder by their percentage."""
material cost; the % of Component Cost rows split the remainder by their percentage."""
total_sm_cost = 0
base_total_sm_cost = 0
precision = self.doc.precision("raw_material_cost")
@@ -279,7 +279,7 @@ class BOMCostingService:
for d in self.doc.get("secondary_items"):
if d.valuation_type not in ("Valuation Rate", "Manual"):
d.cost = flt(allocation_basis * (d.cost_allocation_per / 100), precision)
d.cost = flt(allocation_basis * (flt(d.cost_allocation_per) / 100), precision)
d.base_cost = flt(d.cost * self.doc.conversion_rate, precision)
if save:
d.db_update()

View File

@@ -610,7 +610,7 @@ class TestBOM(ERPNextTestSuite):
"secondary_item_type": "Additional Finished Good",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
@@ -645,7 +645,7 @@ class TestBOM(ERPNextTestSuite):
"secondary_item_type": "Scrap",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
self.assertRaises(frappe.ValidationError, bom_doc.save)
@@ -718,7 +718,7 @@ class TestBOM(ERPNextTestSuite):
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
bom_doc.save()

View File

@@ -138,7 +138,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.valuation_type == '% of FG Cost'",
"depends_on": "eval:doc.valuation_type == '% of Component Cost'",
"fieldname": "cost_allocation_per",
"fieldtype": "Percent",
"label": "Cost Allocation %",
@@ -178,11 +178,11 @@
},
{
"default": "Valuation Rate",
"description": "Valuation Rate and Manual value this item on its own and deduct that cost from the raw material cost, like the pre-v16 scrap items. % of FG Cost allocates a percentage of the remaining raw material cost.",
"description": "Valuation Rate and Manual value this item on its own and deduct that cost from the raw material cost, like the pre-v16 scrap items. % of Component Cost allocates a percentage of the remaining raw material cost.",
"fieldname": "valuation_type",
"fieldtype": "Select",
"label": "Valuation Type",
"options": "Valuation Rate\n% of FG Cost\nManual",
"options": "Valuation Rate\n% of Component Cost\nManual",
"reqd": 1,
"show_description_on_click": 1
},

View File

@@ -32,7 +32,7 @@ class BOMSecondaryItem(Document):
stock_qty: DF.Float
stock_uom: DF.Link | None
uom: DF.Link
valuation_type: DF.Literal["Valuation Rate", "% of FG Cost", "Manual"]
valuation_type: DF.Literal["Valuation Rate", "% of Component Cost", "Manual"]
# end: auto-generated types
pass

View File

@@ -102,6 +102,9 @@ def make_stock_entry(source_name: str, target_doc: str | dict | Document | None
target.qty = pending_rm_qty
def set_missing_values(source, target):
if not source.items:
frappe.throw(_("This Job Card has no raw materials to transfer."))
if source.finished_good and not source.target_warehouse:
frappe.throw(_("Please set the Target Warehouse in the Job Card"))

View File

@@ -1497,7 +1497,7 @@ class TestJobCard(ERPNextTestSuite):
"qty": 1,
"process_loss_per": 10,
"cost_allocation_per": 5,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
"secondary_item_type": "Scrap",
},
)
@@ -2956,7 +2956,7 @@ class TestJobCard(ERPNextTestSuite):
"secondary_item_type": "Scrap",
"qty": 1,
"cost_allocation_per": cost_allocation_per,
"valuation_type": "% of FG Cost" if cost_allocation_per else "Valuation Rate",
"valuation_type": "% of Component Cost" if cost_allocation_per else "Valuation Rate",
},
)
bom_doc.save()
@@ -3012,7 +3012,7 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(rows[bom_links[0]].qty, 2)
self.assertEqual(rows[bom_links[0]].valuation_type, "Valuation Rate")
self.assertEqual(rows[bom_links[1]].qty, 3)
self.assertEqual(rows[bom_links[1]].valuation_type, "% of FG Cost")
self.assertEqual(rows[bom_links[1]].valuation_type, "% of Component Cost")
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"overproduction_percentage_for_work_order": 100}
@@ -3251,6 +3251,98 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(s.additional_costs[2].amount, 480)
self.assertEqual(s.additional_costs[3].amount, 480)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
def test_stock_entry_needs_a_job_card_item_reference(self):
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
job_card = frappe.db.get_value("Job Card", {"work_order": work_order.name})
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.job_card = job_card
stock_entry.purpose = "Material Transfer for Manufacture"
stock_entry.append(
"items",
{
"item_code": "_Test Item",
"s_warehouse": "Stores - _TC",
"qty": 1,
"job_card_item": None,
},
)
self.assertRaisesRegex(
frappe.ValidationError,
"job card item reference is missing",
stock_entry.validate_job_card_item,
)
def test_stock_entry_finished_good_must_match_the_job_card(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
raw_material = make_item("_Test JC FG Check RM", {"is_stock_item": 1}).name
finished_good = make_item("_Test JC FG Check FG", {"is_stock_item": 1}).name
unrelated_item = make_item("_Test JC FG Check Other", {"is_stock_item": 1}).name
operation = {
"operation": "_Test JC FG Check Op",
"workstation": "_Test Workstation A",
"finished_good": finished_good,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=finished_good,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
bom.append("items", {"item_code": raw_material, "qty": 1, "operation_row_id": 1})
bom.append("operations", operation)
bom.insert()
bom.submit()
work_order = make_wo_order_test_record(
item=finished_good,
qty=1,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
job_card = frappe.db.get_value("Job Card", {"work_order": work_order.name})
self.assertEqual(frappe.db.get_value("Job Card", job_card, "finished_good"), finished_good)
mismatched = frappe.new_doc("Stock Entry")
mismatched.job_card = job_card
mismatched.append("items", {"item_code": unrelated_item, "is_finished_item": 1, "qty": 1})
self.assertRaisesRegex(
frappe.ValidationError,
f"Finished Good must be {finished_good}",
mismatched.validate_job_card_fg_item,
)
matching = frappe.new_doc("Stock Entry")
matching.job_card = job_card
matching.append("items", {"item_code": finished_good, "is_finished_item": 1, "qty": 1})
matching.validate_job_card_fg_item()
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -57,20 +57,25 @@ class MaterialRequestService:
"""Create Material Requests grouped by Sales Order and Material Request Type"""
self.validate_mr_subcontracted()
if all(item.requested_qty == item.quantity for item in self.doc.mr_items):
msgprint(_("All items are already requested"))
return
material_request_map = {}
material_request_list = []
for item in self.doc.mr_items:
if item.quantity == item.requested_qty:
qty_to_request = flt(flt(item.quantity) - flt(item.requested_qty), item.precision("quantity"))
if qty_to_request <= 0:
continue
self._add_item_to_material_request(item, material_request_map, material_request_list)
self._add_item_to_material_request(
item, qty_to_request, material_request_map, material_request_list
)
if not material_request_list:
msgprint(_("All items are already requested"))
return
self._submit_material_requests(material_request_list)
def _add_item_to_material_request(self, item, material_request_map, material_request_list):
def _add_item_to_material_request(
self, item, qty_to_request, material_request_map, material_request_list
):
item_doc = frappe.get_cached_doc("Item", item.item_code)
material_request_type = item.material_request_type or item_doc.default_material_request_type
@@ -81,7 +86,7 @@ class MaterialRequestService:
material_request_list.append(material_request_map[key])
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
row = self._material_request_item(item, material_request_type, schedule_date)
row = self._material_request_item(item, material_request_type, schedule_date, qty_to_request)
material_request_map[key].append("items", row)
def _new_material_request(self, material_request_type):
@@ -96,7 +101,7 @@ class MaterialRequestService:
)
return mr
def _material_request_item(self, item, material_request_type, schedule_date):
def _material_request_item(self, item, material_request_type, schedule_date, qty_to_request):
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
# a group warehouse cannot receive stock; it must never reach a Material Request line
if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
@@ -111,7 +116,7 @@ class MaterialRequestService:
return {
"item_code": item.item_code,
"from_warehouse": from_warehouse,
"qty": item.quantity - item.requested_qty,
"qty": qty_to_request,
"uom": item.uom,
"schedule_date": schedule_date,
"warehouse": item.warehouse,

View File

@@ -110,6 +110,23 @@ class TestProductionPlan(ERPNextTestSuite):
pln = frappe.get_doc("Production Plan", pln.name)
pln.cancel()
def test_production_plan_material_request_skips_zero_qty_items(self):
pln = create_production_plan(item_code="Test Production Item 1")
zero_qty_item, requested_item = pln.mr_items
zero_qty_item.quantity = "0"
pln.make_material_request()
material_request_items = frappe.get_all(
"Material Request Item",
filters={"production_plan": pln.name},
fields=["item_code", "qty"],
)
self.assertEqual(
material_request_items,
[{"item_code": requested_item.item_code, "qty": requested_item.quantity}],
)
def test_production_plan_start_date(self):
"Test if Work Order has same Planned Start Date as Prod Plan."
planned_date = add_to_date(date=None, days=3)
@@ -4081,7 +4098,7 @@ def make_bom(**args):
"stock_uom": item_doc.stock_uom,
"qty": args.scrap_qty or 1,
"cost_allocation_per": args.scrap_cost_allocation_per or 10,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
"process_loss_per": args.scrap_process_loss_per or 10,
},
)

View File

@@ -5403,7 +5403,7 @@ class TestWorkOrder(ERPNextTestSuite):
"item_name": scrap_item,
"qty": 3,
"cost_allocation_per": 25,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
"process_loss_per": 0,
},
)
@@ -5452,7 +5452,7 @@ class TestWorkOrder(ERPNextTestSuite):
"item_name": scrap_item,
"qty": 3,
"cost_allocation_per": 25,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
"process_loss_per": 0,
},
)

View File

@@ -139,6 +139,10 @@ frappe.ui.form.on("Work Order", {
frm.fields_dict["secondary_items"].grid.wrapper?.find("> .control-label").text(label);
},
company: function (frm) {
erpnext.work_order.set_default_warehouse(frm);
},
source_warehouse: function (frm) {
let transaction_controller = new erpnext.TransactionController();
transaction_controller.autofill_warehouse(
@@ -1114,14 +1118,16 @@ erpnext.work_order = {
},
set_default_warehouse: function (frm) {
if (!(frm.doc.wip_warehouse || frm.doc.fg_warehouse)) {
if (frm.doc.company && !(frm.doc.wip_warehouse || frm.doc.fg_warehouse)) {
let company = frm.doc.company;
frappe.call({
method: "erpnext.manufacturing.doctype.work_order.work_order.get_default_warehouse",
args: {
company: frm.doc.company,
company: company,
},
callback: function (r) {
if (!r.exe) {
// ignore stale responses if the company changed while the request was in flight
if (!r.exe && frm.doc.company === company) {
frm.set_value("wip_warehouse", r.message.wip_warehouse);
frm.set_value("fg_warehouse", r.message.fg_warehouse);
frm.set_value("scrap_warehouse", r.message.scrap_warehouse);

View File

@@ -3,18 +3,95 @@
import frappe
from frappe import _
from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom
from erpnext.manufacturing.doctype.workstation.workstation import (
NotInWorkingHoursError,
WorkstationHolidayError,
check_if_within_operating_hours,
get_raw_materials,
update_job_card,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestWorkstation(ERPNextTestSuite):
def test_get_raw_materials_without_items(self):
for skip_transfer, backflush_from_wip in ((0, 0), (1, 0), (1, 1)):
with self.subTest(skip_transfer=skip_transfer, backflush_from_wip=backflush_from_wip):
job_card = frappe.get_doc(
{
"doctype": "Job Card",
"company": "_Test Company",
"skip_material_transfer": skip_transfer,
"backflush_from_wip_warehouse": backflush_from_wip,
"wip_warehouse": "_Test Warehouse 1 - _TC",
}
).insert(ignore_mandatory=True)
for method in (get_raw_materials, make_stock_entry):
with self.subTest(method=method.__name__):
with self.assertRaisesRegex(
frappe.ValidationError, "This Job Card has no raw materials to transfer"
):
method(job_card.name)
job_card.reload()
self.assertFalse(job_card.items)
self.assertFalse(frappe.db.exists("Stock Entry", {"job_card": job_card.name}))
def test_get_raw_materials_availability(self):
for skip_transfer, backflush_from_wip, transferred_qty in (
(0, 0, 2),
(0, 0, 5),
(1, 0, 0),
(1, 1, 0),
):
with self.subTest(
skip_transfer=skip_transfer,
backflush_from_wip=backflush_from_wip,
transferred_qty=transferred_qty,
):
job_card = frappe.get_doc(
{
"doctype": "Job Card",
"company": "_Test Company",
"skip_material_transfer": skip_transfer,
"backflush_from_wip_warehouse": backflush_from_wip,
"wip_warehouse": "_Test Warehouse 1 - _TC",
"items": [
{
"item_code": "_Test Item",
"source_warehouse": "_Test Warehouse - _TC",
"required_qty": 5,
"transferred_qty": transferred_qty,
},
],
}
).insert(ignore_mandatory=True)
materials = get_raw_materials(job_card.name)
self.assertEqual(len(materials), 1)
material = materials[0]
warehouse = "_Test Warehouse 1 - _TC" if backflush_from_wip else "_Test Warehouse - _TC"
stock_qty = (
frappe.db.get_value(
"Bin", {"item_code": "_Test Item", "warehouse": warehouse}, "actual_qty"
)
or 0
)
self.assertEqual(material.item_code, "_Test Item")
self.assertEqual(material.required_qty, 5)
self.assertEqual(material.transferred_qty, transferred_qty)
self.assertEqual(material.warehouse, warehouse)
self.assertEqual(material.stock_qty, stock_qty)
self.assertEqual(
material.material_availability_status,
int(stock_qty >= 5) if skip_transfer else int(transferred_qty >= 5),
)
def test_update_job_card_rejects_disallowed_method(self):
# The whitelisted update_job_card endpoint must only run an allowlisted set of Job Card
# methods. An arbitrary method name must be rejected (PermissionError) before the document

View File

@@ -287,8 +287,8 @@ def get_raw_materials(job_card: str):
filters={"name": job_card},
)
if not raw_materials:
return []
if not raw_materials or not raw_materials[0].item_code:
frappe.throw(_("This Job Card has no raw materials to transfer."))
for row in raw_materials:
warehouse = row.source_warehouse

View File

@@ -1113,7 +1113,7 @@ class MaterialRequirementsPlanningReport:
args["to_date"] = add_days(from_date, -1)
if bucket_size == "Monthly":
args["label"] = formatdate(from_date, "MMM YYYY")
args["label"] = formatdate(args["from_date"], "MMM YYYY")
else:
if bucket_size == "Weekly":
args["label"] = (

View File

@@ -520,3 +520,6 @@ erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.set_secondary_item_valuation_type
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.set_supplier_quotation_order_status
erpnext.patches.v16_0.recalculate_holiday_list_totals
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
erpnext.patches.v16_0.rename_component_cost_valuation_type

View File

@@ -8,10 +8,12 @@ def execute():
"fieldname": "service_level_agreement",
"fieldtype": "Link",
"options": "Service Level Agreement",
"link_filters": ("is", "not set"),
},
fields=["name", "dt"],
fields=["name", "dt", "link_filters"],
):
if custom_field.link_filters:
continue
link_filters = frappe.as_json(
[["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None
)

View File

@@ -9,10 +9,12 @@ def execute():
"fieldname": "service_level_agreement",
"fieldtype": "Link",
"options": "Service Level Agreement",
"link_filters": ("is", "not set"),
},
fields=["name", "parent"],
fields=["name", "parent", "link_filters"],
):
if docfield.link_filters:
continue
link_filters = frappe.as_json(
[["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None
)

View File

@@ -0,0 +1,18 @@
import frappe
from frappe.query_builder import Case
from frappe.query_builder.functions import Coalesce, Sum
def execute():
holiday_list = frappe.qb.DocType("Holiday List")
holiday = frappe.qb.DocType("Holiday")
total_holidays = (
frappe.qb.from_(holiday)
.select(Sum(Case().when(holiday.is_half_day == 1, 0.5).else_(1)))
.where(
(holiday.parent == holiday_list.name)
& (holiday.parenttype == "Holiday List")
& (holiday.parentfield == "holidays")
)
)
frappe.qb.update(holiday_list).set(holiday_list.total_holidays, Coalesce(total_holidays, 0)).run()

View File

@@ -0,0 +1,32 @@
import frappe
def execute():
"""Recalculate billing status of Delivery Notes left open by a return.
Returning the uninvoiced qty of a Delivery Note did not recalculate the original
Delivery Note, so it stayed "To Bill" / "Partially Billed" with nothing left to invoice.
"""
dn = frappe.qb.DocType("Delivery Note")
dn_item = frappe.qb.DocType("Delivery Note Item")
delivery_notes = (
frappe.qb.from_(dn)
.inner_join(dn_item)
.on(dn_item.parent == dn.name)
.select(dn.name)
.distinct()
.where(
(dn.docstatus == 1)
& (dn.is_return == 0)
& dn.status.isin(["To Bill", "Partially Billed"])
& (dn_item.returned_qty > 0)
)
.run(pluck=True)
)
for name in delivery_notes:
doc = frappe.get_doc("Delivery Note", name)
doc.update_billing_percentage(update_modified=False)
doc.load_from_db()
doc.set_status(update=True, update_modified=False)

View File

@@ -0,0 +1,15 @@
import frappe
DOCTYPES = ("BOM Secondary Item", "Stock Entry Detail", "Subcontracting Receipt Item")
def execute():
"""Rename the `% of FG Cost` valuation type: the percentage is of the component cost."""
for doctype in DOCTYPES:
frappe.db.set_value(
doctype,
{"valuation_type": "% of FG Cost"},
"valuation_type",
"% of Component Cost",
update_modified=False,
)

View File

@@ -69,7 +69,7 @@ erpnext.buying = {
if (this.frm.fields_dict.buying_price_list) {
this.frm.set_query("buying_price_list", function () {
return {
filters: { buying: 1 },
filters: { buying: 1, enabled: 1 },
};
});
}
@@ -541,7 +541,7 @@ erpnext.buying.link_to_mrs = function (frm) {
var item_length = frm.doc.items.length;
for (let item of frm.doc.items) {
var qty = item.qty;
(r.message[0] || []).forEach(function (d) {
(r.message || []).forEach(function (d) {
if (
d.qty > 0 &&
qty > 0 &&

View File

@@ -1150,11 +1150,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
if (this.frm.doc.set_posting_time) return;
if (frappe.datetime.get_today() == this.frm.doc.posting_date) return;
let is_confirmation_reqd = await frappe.db.get_single_value(
"Accounts Settings",
"confirm_before_resetting_posting_date"
const is_confirmation_reqd = await frappe.xcall(
"erpnext.accounts.doctype.accounts_settings.accounts_settings.get_posting_date_confirmation"
);
if (!is_confirmation_reqd) return;
return new Promise((resolve, reject) => {

View File

@@ -727,7 +727,7 @@
<button class="btn btn-primary mes-btn-start" data-job-card="{{ frappe.utils.escape_html(slot.name) }}">
{{ __("Start Job") }} &rarr;
</button>
{% } else { %}
{% } else if (slot.materials && slot.materials.length) { %}
<button class="btn btn-default mes-btn-transfer" data-job-card="{{ frappe.utils.escape_html(slot.name) }}">
{{ __("Transfer Materials") }}
</button>
@@ -990,7 +990,7 @@
<button class="btn btn-default btn-sm mes-btn-start" data-job-card="{{ frappe.utils.escape_html(jc.name) }}">
{{ __("Start") }}
</button>
{% } else { %}
{% } else if (jc.materials && jc.materials.length) { %}
<button class="btn btn-default btn-sm mes-btn-transfer" data-job-card="{{ frappe.utils.escape_html(jc.name) }}">
{{ __("Transfer") }}
</button>

View File

@@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
args = {
party: frm.doc.customer || frm.doc.party_name,
party_type: party_type,
price_list: frm.doc.selling_price_list,
price_list: frappe.defaults.get_default("selling_price_list"),
};
} else if (frm.doc.supplier) {
args = {
party: frm.doc.supplier,
party_type: "Supplier",
bill_date: frm.doc.bill_date,
price_list: frm.doc.buying_price_list,
price_list: frappe.defaults.get_default("buying_price_list"),
};
}

View File

@@ -60,7 +60,7 @@ erpnext.sales_common = {
if (this.frm.fields_dict.selling_price_list) {
this.frm.set_query("selling_price_list", function () {
return { filters: { selling: 1 } };
return { filters: { selling: 1, enabled: 1 } };
});
}

View File

@@ -31,20 +31,4 @@ frappe.query_reports["IRS 1099"] = {
width: 80,
},
],
onload: function (query_report) {
query_report.page.add_inner_button(__("Print IRS 1099 Forms"), () => {
build_1099_print(query_report);
});
},
};
function build_1099_print(query_report) {
let filters = JSON.stringify(query_report.get_values());
let w = window.open(
"/api/method/erpnext.regional.report.irs_1099.irs_1099.irs_1099_print?" +
"&filters=" +
encodeURIComponent(filters)
);
// w.print();
}

View File

@@ -83,46 +83,6 @@ def get_columns():
]
@frappe.whitelist()
def irs_1099_print(filters: str | dict):
if not filters:
frappe._dict(
{
"company": frappe.db.get_default("Company"),
"fiscal_year": frappe.db.get_default("Fiscal Year"),
}
)
else:
filters = frappe._dict(frappe.parse_json(filters))
fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True)
fiscal_year = cstr(fiscal_year_doc.year_start_date.year)
company_address = get_payer_address_html(filters.company)
company_tin = frappe.db.get_value("Company", filters.company, "tax_id")
columns, data = execute(filters)
template = frappe.get_doc("Print Format", "IRS 1099 Form").html
output = PdfWriter()
for row in data:
row["fiscal_year"] = fiscal_year
row["company"] = filters.company
row["company_tin"] = company_tin
row["payer_street_address"] = company_address
row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html(
"Supplier", row.supplier
)
row["payments"] = fmt_money(row["payments"], precision=0, currency="USD")
get_pdf(render_template(template, row), output=output if output else None)
frappe.local.response.filename = (
f"{filters.fiscal_year} {filters.company} IRS 1099 Forms{IRS_1099_FORMS_FILE_EXTENSION}"
)
frappe.local.response.filecontent = read_multi_pdf(output)
frappe.local.response.type = "download"
def get_payer_address_html(company):
address = frappe.qb.DocType("Address")
address_list = (

View File

@@ -558,11 +558,8 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
# if the current user does not have permissions to override credit limit,
# prompt them to send out an email to the controller users
frappe.msgprint(
message,
title=_("Credit Limit Crossed"),
raise_exception=1,
primary_action={
primary_action = (
{
"label": "Send Email",
"server_action": "erpnext.selling.doctype.customer.customer.send_emails",
"hide_on_success": True,
@@ -572,7 +569,16 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
"credit_limit": credit_limit,
"credit_controller_users_list": credit_controller_users,
},
},
}
if frappe.has_permission("Customer", ptype="email", doc=customer)
else None
)
frappe.msgprint(
message,
title=_("Credit Limit Crossed"),
raise_exception=1,
primary_action=primary_action,
)
@@ -580,6 +586,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
def send_emails(
customer: str, customer_outstanding: float, credit_limit: float, credit_controller_users_list: str | list
):
frappe.has_permission("Customer", ptype="email", doc=customer, throw=True)
credit_controller_users_list = frappe.parse_json(credit_controller_users_list)
subject = _("Credit limit reached for customer {0}").format(customer)
message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format(

View File

@@ -212,6 +212,7 @@ def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
@frappe.whitelist()
def send_proforma_email(proforma_name: str, recipients: str) -> None:
proforma = frappe.get_doc("Proforma Invoice", proforma_name)
proforma.check_permission("email")
if proforma.docstatus != 1:
frappe.throw(_("Only an issued Proforma Invoice can be emailed."))
if not proforma.proforma_pdf:

View File

@@ -10,7 +10,7 @@ import frappe.utils
from frappe import _, qb
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import Abs, Sum
from frappe.query_builder.functions import Abs, IfNull, Round, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate
from pypika import Order
@@ -952,8 +952,26 @@ def get_stock_reservation_status():
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
def get_pending_qty_criterion(sales_order_item):
"""Mirror the mapper's pending quantity check."""
invoice_item = qb.DocType("Sales Invoice Item")
billed_qty = (
qb.from_(invoice_item)
.select(IfNull(Sum(invoice_item.qty), 0))
.where((invoice_item.docstatus == 1) & (invoice_item.so_detail == sales_order_item.name))
)
qty_precision = frappe.get_precision("Sales Order Item", "qty")
has_unbilled_ordered_qty = Round(sales_order_item.qty - billed_qty, qty_precision) > 0
has_unbilled_delivered_qty = (
Round(sales_order_item.qty - sales_order_item.returned_qty - billed_qty, qty_precision) > 0
) | (Round(sales_order_item.delivered_qty - billed_qty, qty_precision) > 0)
return has_unbilled_ordered_qty & has_unbilled_delivered_qty
def get_potentially_billable_item_criterion(sales_order, sales_order_item, item):
"""Return the amount check for UI candidates. The mapper checks pending quantity."""
"""Return the row level checks the Sales Invoice mapper applies."""
global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance"))
allowance = (
Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
@@ -963,11 +981,12 @@ def get_potentially_billable_item_criterion(sales_order, sales_order_item, item)
Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100)
)
is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0)
return (sales_order_item.closed == 0) & (
is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
is_billable_row = (
(sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item)
)
return (sales_order_item.closed == 0) & (is_unit_price_row | is_billable_row)
def has_potentially_billable_items(sales_order: str) -> bool:
"""Return whether a Sales Order has an item with billing amount headroom."""
@@ -1016,7 +1035,7 @@ def get_potentially_billable_sales_orders(
query = frappe.qb.get_query(
so,
fields=[so.name, so.customer, so.transaction_date],
fields=[so.name, so.customer, so.transaction_date, so.creation],
filters=filters,
or_filters=or_filters,
ignore_permissions=False,
@@ -1029,7 +1048,8 @@ def get_potentially_billable_sales_orders(
.on(item.name == so_item.item_code)
.where(get_potentially_billable_item_criterion(so, so_item, item))
.distinct()
.orderby(so.transaction_date, order=Order.desc)
.orderby(so.transaction_date, order=Order.asc)
.orderby(so.creation, order=Order.asc)
.limit(cint(page_len))
.offset(cint(start))
.run(as_dict=True)

View File

@@ -440,6 +440,48 @@ class TestSalesOrder(ERPNextTestSuite):
self.assertEqual(len(make_sales_invoice(so.name).items), 0)
def test_fully_billed_order_is_not_offered_within_billing_allowance(self):
item = make_item(
"_Test Fully Billed Allowance Item",
{"is_stock_item": 1, "over_billing_allowance": 0},
).name
so = make_sales_order(item_code=item, qty=10, rate=100)
si = make_sales_invoice(so.name)
si.insert()
si.submit()
so.load_from_db()
self.assertEqual(flt(so.per_billed), 100)
filters = {"docstatus": 1, "company": so.company, "customer": so.customer}
with change_settings("Accounts Settings", {"over_billing_allowance": 100}):
self.assertFalse(has_potentially_billable_items(so.name))
rows = get_potentially_billable_sales_orders("Sales Order", "", "name", 0, 50, filters)
self.assertNotIn(so.name, [row.name for row in rows])
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
def test_order_with_sub_precision_pending_qty_is_not_offered(self):
item = make_item("_Test Sub Precision Qty Item", {"is_stock_item": 1}).name
so = make_sales_order(item_code=item, qty=10, rate=100)
si = make_sales_invoice(so.name)
si.get("items")[0].rate = 90
si.insert()
si.submit()
qty_precision = frappe.get_precision("Sales Order Item", "qty")
billed_qty = 10 - 10 ** -(qty_precision + 1)
frappe.db.set_value(
"Sales Invoice Item", si.get("items")[0].name, "qty", billed_qty, update_modified=False
)
self.assertFalse(has_potentially_billable_items(so.name))
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
def test_make_sales_invoice_after_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return

View File

@@ -1,11 +1,18 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
function update_total_holidays(frm) {
let total_holidays = 0;
for (const holiday of frm.doc.holidays || []) {
total_holidays += holiday.is_half_day ? 0.5 : 1;
}
frm.doc.total_holidays = total_holidays;
frm.refresh_field("total_holidays");
}
frappe.ui.form.on("Holiday List", {
refresh: function (frm) {
if (frm.doc.holidays) {
frm.set_value("total_holidays", frm.doc.holidays.length);
}
update_total_holidays(frm);
frm.call("get_supported_countries").then((r) => {
frm.subdivisions_by_country = r.message.subdivisions_by_country;
@@ -43,6 +50,18 @@ frappe.ui.form.on("Holiday List", {
},
});
frappe.ui.form.on("Holiday", {
holidays_add: function (frm) {
update_total_holidays(frm);
},
holidays_remove: function (frm) {
update_total_holidays(frm);
},
is_half_day: function (frm) {
update_total_holidays(frm);
},
});
frappe.tour["Holiday List"] = [
{
fieldname: "holiday_list_name",

View File

@@ -58,9 +58,10 @@
},
{
"fieldname": "total_holidays",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Total Holidays",
"precision": "1",
"read_only": 1
},
{

View File

@@ -8,7 +8,7 @@ from datetime import date
import frappe
from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import DateTimeLikeObject, formatdate, getdate, today
from frappe.utils import DateTimeLikeObject, cint, formatdate, getdate, today
class OverlapError(frappe.ValidationError):
@@ -34,7 +34,7 @@ class HolidayList(Document):
is_half_day: DF.Check
subdivision: DF.Autocomplete | None
to_date: DF.Date
total_holidays: DF.Int
total_holidays: DF.Float
weekly_off: DF.Literal[
"", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
]
@@ -42,10 +42,13 @@ class HolidayList(Document):
def validate(self):
self.validate_days()
self.total_holidays = len(self.holidays)
self.update_total_holidays()
self.validate_duplicate_date()
self.sort_holidays()
def update_total_holidays(self):
self.total_holidays = sum(0.5 if cint(holiday.is_half_day) else 1 for holiday in self.holidays)
@frappe.whitelist()
def get_weekly_off_dates(self):
if not self.weekly_off:
@@ -67,6 +70,8 @@ class HolidayList(Document):
},
)
self.update_total_holidays()
@frappe.whitelist()
def get_supported_countries(self):
from holidays.utils import list_supported_countries
@@ -108,6 +113,8 @@ class HolidayList(Document):
"holidays", {"description": holiday_name, "holiday_date": holiday_date, "weekly_off": 0}
)
self.update_total_holidays()
def sort_holidays(self):
self.holidays.sort(key=lambda x: (x.weekly_off, getdate(x.holiday_date)))
for i in range(len(self.holidays)):
@@ -153,6 +160,7 @@ class HolidayList(Document):
@frappe.whitelist()
def clear_table(self):
self.set("holidays", [])
self.update_total_holidays()
def validate_duplicate_date(self):
unique_dates = []

View File

@@ -4,7 +4,7 @@ from contextlib import contextmanager
from datetime import date, timedelta
import frappe
from frappe.utils import getdate
from frappe.utils import get_datetime, getdate
from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name
from erpnext.tests.utils import ERPNextTestSuite
@@ -45,6 +45,94 @@ class TestHolidayList(ERPNextTestSuite):
self.assertIn(date(2023, 2, 26), holidays)
self.assertNotIn(date(2023, 3, 5), holidays)
def test_total_holidays_includes_half_days(self):
holiday_list = make_holiday_list(
"test_half_day_holiday_list",
from_date="2023-01-01",
to_date="2023-01-03",
holiday_dates=[
{"holiday_date": "2023-01-01", "description": "Full-day holiday"},
{
"holiday_date": "2023-01-02",
"description": "Half-day holiday",
"is_half_day": 1,
},
],
)
self.assertEqual(holiday_list.total_holidays, 1.5)
self.assertEqual(frappe.db.get_value("Holiday List", holiday_list.name, "total_holidays"), 1.5)
def test_weekly_off_updates_total_without_saving(self):
holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2023-01-01"
holiday_list.to_date = "2023-01-14"
holiday_list.weekly_off = "Saturday"
holiday_list.is_half_day = 1
holiday_list.append("holidays", {"holiday_date": "2023-01-01", "description": "Full day"})
holiday_list.get_weekly_off_dates()
self.assertEqual(len(holiday_list.holidays), 3)
self.assertEqual(holiday_list.total_holidays, 2)
holiday_list.get_weekly_off_dates()
self.assertEqual(len(holiday_list.holidays), 3)
self.assertEqual(holiday_list.total_holidays, 2)
holiday_list.clear_table()
self.assertEqual(holiday_list.holidays, [])
self.assertEqual(holiday_list.total_holidays, 0)
def test_local_holidays_updates_total_without_saving(self):
holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2023-01-01"
holiday_list.to_date = "2023-01-02"
holiday_list.country = "DE"
holiday_list.append(
"holidays", {"holiday_date": "2023-01-02", "description": "Half day", "is_half_day": 1}
)
holiday_list.get_local_holidays()
self.assertEqual(len(holiday_list.holidays), 2)
self.assertEqual(holiday_list.total_holidays, 1.5)
holiday_list.get_local_holidays()
self.assertEqual(len(holiday_list.holidays), 2)
self.assertEqual(holiday_list.total_holidays, 1.5)
def test_recalculate_existing_holiday_list_totals(self):
from erpnext.patches.v16_0.recalculate_holiday_list_totals import execute
cases = (("mixed", [0, 1], 1.5), ("half", [1, 1, 1], 1.5), ("full", [0, 0], 2), ("empty", [], 0))
holiday_lists = []
for name, half_days, expected in cases:
holiday_list = make_holiday_list(
f"test_backfill_holidays_{name}",
from_date="2023-01-01",
to_date="2023-01-03",
holiday_dates=[
{
"holiday_date": date(2023, 1, idx),
"description": "Test holiday",
"is_half_day": is_half_day,
}
for idx, is_half_day in enumerate(half_days, start=1)
],
)
# Simulate totals persisted by the old controller, including a stale empty list.
holiday_list.db_set("total_holidays", len(half_days) or 1, update_modified=False)
holiday_lists.append((holiday_list, expected))
for _ in range(2):
execute()
for holiday_list, expected in holiday_lists:
with self.subTest(holiday_list=holiday_list.name):
total, modified = frappe.db.get_value(
"Holiday List", holiday_list.name, ["total_holidays", "modified"]
)
self.assertEqual(total, expected)
self.assertEqual(modified, get_datetime(holiday_list.modified))
def test_local_holidays(self):
holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2022-01-01"

View File

@@ -7,6 +7,7 @@ from frappe.defaults import get_user_default
from frappe.utils import cint
import erpnext.accounts.utils
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
def boot_session(bootinfo):
@@ -28,6 +29,8 @@ def boot_session(bootinfo):
frappe.get_single_value("Accounts Settings", "disable_include_dimensions")
)
remove_disabled_price_list_defaults(bootinfo)
bootinfo.sysdefaults.quotation_valid_till = cint(
frappe.db.get_single_value("CRM Settings", "default_valid_till")
)
@@ -81,6 +84,18 @@ def boot_session(bootinfo):
bootinfo.sysdefaults.repost_allowed_doctypes = frappe.get_hooks("repost_allowed_doctypes")
def remove_disabled_price_list_defaults(bootinfo):
user_defaults = (bootinfo.user or {}).get("defaults") or {}
for key in ("selling_price_list", "buying_price_list"):
price_list = bootinfo.sysdefaults.get(key) or user_defaults.get(key)
if not isinstance(price_list, str) or is_price_list_enabled(price_list):
continue
bootinfo.sysdefaults.pop(key, None)
user_defaults.pop(key, None)
def update_page_info(bootinfo):
bootinfo.page_info.update(
{

View File

@@ -20,3 +20,25 @@ class TestBoot(ERPNextTestSuite):
company_docs = [d for d in bootinfo.docs if d.get("doctype") == ":Company"]
self.assertTrue(any(d.get("name") == "_Test Company" for d in company_docs))
def test_boot_session_drops_disabled_price_list_default(self):
from erpnext.startup.boot import boot_session
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": 1,
"enabled": 0,
}
).insert(ignore_permissions=True)
bootinfo = frappe._dict(
sysdefaults=frappe._dict(selling_price_list=price_list.name),
page_info=frappe._dict(),
docs=[],
)
boot_session(bootinfo)
self.assertIsNone(bootinfo.sysdefaults.get("selling_price_list"))

View File

@@ -57,12 +57,8 @@ def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True
account = warehouse.account
if not account and warehouse.parent_warehouse:
if warehouse_account:
if warehouse_account.get(warehouse.parent_warehouse):
account = warehouse_account.get(warehouse.parent_warehouse).account
else:
from frappe.utils.nestedset import rebuild_tree
rebuild_tree("Warehouse")
if parent := warehouse_account.get(warehouse.parent_warehouse):
account = parent.account
else:
account = frappe.get_all(
"Warehouse",

View File

@@ -11,6 +11,8 @@ from pypika.terms import Bracket, ExistsCriterion
RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier")
RESTRICTION_INHERITED_FROM = {"Item Price": ("Item", "item_code")}
COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset(
{
"Asset",
@@ -71,6 +73,25 @@ def get_permission_query_conditions(user, doctype=None):
return get_restriction_criterion(doctype, allowed_companies)
def get_inherited_permission_query_conditions(user, doctype=None):
if not (inherited := RESTRICTION_INHERITED_FROM.get(doctype)):
return None
master_doctype, fieldname = inherited
allowed_companies = get_allowed_companies(user, master_doctype)
if not allowed_companies:
return None
child = frappe.qb.DocType(doctype)
master = frappe.qb.DocType(master_doctype)
allowed_masters = (
frappe.qb.from_(master)
.select(master.name)
.where(get_restriction_criterion(master_doctype, allowed_companies))
)
return child[fieldname].isin(allowed_masters)
def get_restriction_criterion(doctype, companies):
parent = frappe.qb.DocType(doctype)
restriction = frappe.qb.DocType("Company Restriction")
@@ -98,6 +119,17 @@ def has_permission(doc, ptype=None, user=None):
return any(row.company in allowed_companies for row in doc.get("allowed_companies") or [])
def has_inherited_permission(doc, ptype=None, user=None):
if not (inherited := RESTRICTION_INHERITED_FROM.get(doc.doctype)):
return True
master_doctype, fieldname = inherited
if not (master_name := doc.get(fieldname)):
return True
return has_permission(frappe.get_cached_doc(master_doctype, master_name), ptype, user)
def validate_allowed_companies(doc, method=None):
if not doc.get("restrict_to_companies"):
doc.set("allowed_companies", [])

View File

@@ -98,15 +98,7 @@ class TestCompanyRestriction(ERPNextTestSuite):
def test_unrestricted_party_ignores_company_permission(self):
customer = make_customer("_Test Party Details Company Permission Customer")
user = self.make_user_with_roles("test_party_details_company@example.com", ["Sales User"])
permission = {
"user": user,
"allow": "Company",
"for_value": "_Test Company 1",
"apply_to_all_doctypes": 1,
}
if not frappe.db.exists("User Permission", permission):
frappe.get_doc({"doctype": "User Permission", **permission}).insert(ignore_permissions=True)
frappe.clear_cache(user=user)
self.allow_company(user, "_Test Company 1")
with self.set_user(user):
results = party_query(
@@ -155,6 +147,61 @@ class TestCompanyRestriction(ERPNextTestSuite):
stock_entry.reload()
stock_entry.cancel()
def allow_company(self, user, company):
permission = {
"user": user,
"allow": "Company",
"for_value": company,
"apply_to_all_doctypes": 1,
}
if not frappe.db.exists("User Permission", permission):
frappe.get_doc({"doctype": "User Permission", **permission}).insert(ignore_permissions=True)
frappe.clear_cache(user=user)
def make_item_price(self, item_code):
return (
frappe.get_doc(
{
"doctype": "Item Price",
"price_list": "_Test Price List",
"item_code": item_code,
"price_list_rate": 100,
}
)
.insert()
.name
)
def test_item_price_inherits_item_company_restriction(self):
restricted = make_item()
allowed = make_item()
self.restrict_to_companies("Item", restricted.name, ["_Test Company 1"])
prices = {item.name: self.make_item_price(item.name) for item in (restricted, allowed)}
user = self.make_user_with_roles("test_item_price_restriction@example.com", ["Sales Master Manager"])
self.allow_company(user, "_Test Company")
with self.set_user(user):
visible = frappe.get_list(
"Item Price",
filters={"item_code": ("in", [restricted.name, allowed.name])},
pluck="item_code",
)
self.assertEqual(visible, [allowed.name])
self.assertFalse(frappe.has_permission("Item Price", doc=prices[restricted.name]))
self.assertTrue(frappe.has_permission("Item Price", doc=prices[allowed.name]))
def test_item_price_is_visible_without_company_permission(self):
restricted = make_item()
self.restrict_to_companies("Item", restricted.name, ["_Test Company 1"])
price = self.make_item_price(restricted.name)
user = self.make_user_with_roles("test_item_price_unrestricted@example.com", ["Sales Master Manager"])
with self.set_user(user):
self.assertTrue(frappe.has_permission("Item Price", doc=price))
def make_user_with_roles(self, email, roles):
if not frappe.db.exists("User", email):
frappe.get_doc(

View File

@@ -29,6 +29,9 @@ class BillingStatusService:
def update_billing_status(self, update_modified: bool = True) -> None:
doc = self.doc
updated_delivery_notes = [doc.name]
if doc.is_return and doc.return_against:
updated_delivery_notes.append(doc.return_against)
for d in doc.get("items"):
if d.si_detail and not d.so_detail:
d.db_set("billed_amt", d.amount, update_modified=update_modified)
@@ -37,7 +40,8 @@ class BillingStatusService:
for dn in set(updated_delivery_notes):
dn_doc = doc if (dn == doc.name) else frappe.get_lazy_doc("Delivery Note", dn)
dn_doc.update_billing_percentage(update_modified=update_modified)
update_dn_modified = update_modified and dn != doc.return_against
dn_doc.update_billing_percentage(update_modified=update_dn_modified)
doc.load_from_db()

View File

@@ -1060,6 +1060,117 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.status, "Completed")
def test_dn_is_completed_when_unbilled_item_is_returned(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
make_stock_entry(target="_Test Warehouse - _TC", qty=1, basic_rate=100)
make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=1, basic_rate=100)
dn = create_delivery_note(do_not_submit=True)
dn.append(
"items",
{
"item_code": "_Test Item 2",
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": 100,
"conversion_factor": 1,
"allow_zero_valuation_rate": 1,
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
dn.submit()
si = make_sales_invoice(dn.name)
si.set("items", [item for item in si.items if item.item_code == "_Test Item"])
si.insert()
si.submit()
dn.reload()
self.assertEqual(dn.per_billed, 50)
self.assertEqual(dn.status, "Partially Billed")
return_dn = make_sales_return(dn.name)
return_dn.set("items", [item for item in return_dn.items if item.item_code == "_Test Item 2"])
return_dn.insert()
# Mimic the submit request, which reconstructs the document from client data.
return_dn = frappe.get_doc(return_dn.as_dict())
return_dn.submit()
dn.reload()
self.assertEqual(dn.items[1].returned_qty, 1)
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.status, "Completed")
return_dn.cancel()
dn.reload()
self.assertEqual(dn.items[1].returned_qty, 0)
self.assertEqual(dn.per_billed, 50)
self.assertEqual(dn.status, "Partially Billed")
def test_billing_status_repair_patch(self):
"""Returns submitted before #58869 left the original Delivery Note's per_billed stale.
The repair patch recalculates such notes: a directly invoiced one whose remaining
qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back
to To Bill.
"""
from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
# Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed
make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100)
dn = create_delivery_note(qty=5)
si = make_sales_invoice(dn.name)
si.items[0].qty = 2
si.insert()
si.submit()
dn_return = make_sales_return(dn.name)
dn_return.items[0].qty = -3
dn_return.insert()
# Mimic the submit request, which reconstructs the document from client data.
frappe.get_doc(dn_return.as_dict()).submit()
dn.load_from_db()
self.assertEqual(dn.items[0].returned_qty, 3)
self.assertEqual(dn.per_billed, 100)
# Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled
so = make_sales_order(qty=10)
so_dn = create_dn_against_so(so.name, delivered_qty=5)
so_dn_return = make_sales_return(so_dn.name)
so_dn_return.items[0].qty = -2
so_dn_return.insert()
frappe.get_doc(so_dn_return.as_dict()).submit()
so_dn.load_from_db()
self.assertEqual(so_dn.items[0].returned_qty, 2)
self.assertEqual(so_dn.per_billed, 0)
# Mimic the state left behind by a return submitted before the fix
for name, per_billed in ((dn.name, 40), (so_dn.name, 50)):
frappe.db.set_value(
"Delivery Note",
name,
{"per_billed": per_billed, "status": "Partially Billed"},
update_modified=False,
)
patch.execute()
dn.load_from_db()
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.status, "Completed")
so_dn.load_from_db()
self.assertEqual(so_dn.per_billed, 0)
self.assertEqual(so_dn.status, "To Bill")
def test_dn_billing_status_case2(self):
# SO -> SI and SO -> DN1, DN2
from erpnext.selling.doctype.sales_order.mapper import (

View File

@@ -54,6 +54,11 @@ class DeliveryTrip(Document):
self.update_status()
self.update_delivery_notes(delete=True)
def after_mapping(self, source_doc):
for stop in self.delivery_stops[:]:
if not any(stop.get(df.fieldname) for df in stop.meta.fields):
self.remove(stop)
def validate(self):
if self._action == "submit" and not self.driver:
frappe.throw(_("A driver must be set to submit."))
@@ -80,7 +85,7 @@ class DeliveryTrip(Document):
def validate_stop_addresses(self):
for stop in self.delivery_stops:
if not stop.customer_address:
if stop.address and not stop.customer_address:
stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict())
def validate_delivery_note_not_draft(self):

View File

@@ -6,6 +6,7 @@ import frappe
from frappe.utils import add_days, flt, now_datetime, nowdate
import erpnext
from erpnext.stock.doctype.delivery_note.mapper import make_delivery_trip
from erpnext.stock.doctype.delivery_trip.delivery_trip import (
get_contact_and_address,
get_default_contact,
@@ -174,6 +175,32 @@ class TestDeliveryTrip(ERPNextTestSuite):
self.assertEqual(result.parent, orphan_parent)
self.assertIsNone(result.is_primary_contact)
def map_delivery_note_onto_trip(self, existing_stop):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
delivery_note = create_delivery_note()
trip = frappe.new_doc("Delivery Trip")
trip.append("delivery_stops", existing_stop)
return delivery_note, make_delivery_trip(delivery_note.name, trip)
def test_mapping_drops_placeholder_stop(self):
delivery_note, trip = self.map_delivery_note_onto_trip({})
self.assertEqual(len(trip.delivery_stops), 1)
self.assertEqual(trip.delivery_stops[0].delivery_note, delivery_note.name)
def test_mapping_keeps_partially_filled_stop(self):
_, trip = self.map_delivery_note_onto_trip({"customer": "_Test Customer"})
self.assertEqual(len(trip.delivery_stops), 2)
self.assertIsNone(trip.delivery_stops[0].delivery_note)
def test_stop_without_address_throws_mandatory_error(self):
self.delivery_trip.append("delivery_stops", {"customer": "_Test Customer"})
self.assertRaises(frappe.MandatoryError, self.delivery_trip.save)
def create_address(driver):
if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}):

View File

@@ -73,24 +73,44 @@ frappe.ui.form.on("Inventory Dimension", {
frm.trigger("set_parent_fields");
},
set_parent_fields(frm) {
if (frm.doc.apply_to_all_doctypes) {
let options = ["\n", frm.doc.reference_document];
istable(frm) {
frm.trigger("set_parent_fields");
},
frm.set_df_property("fetch_from_parent", "options", options);
} else if (frm.doc.document_type && frm.doc.istable) {
reference_document(frm) {
frm.trigger("set_parent_fields");
},
apply_to_all_doctypes(frm) {
frm.trigger("set_parent_fields");
},
set_parent_fields(frm) {
const { reference_document, document_type } = frm.doc;
if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) {
return set_parent_field_options(frm, []);
}
if (frm.doc.apply_to_all_doctypes) {
return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]);
} else if (document_type && frm.doc.istable) {
frappe.call({
method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields",
args: {
child_doctype: frm.doc.document_type,
dimension_name: frm.doc.reference_document,
child_doctype: document_type,
dimension_name: reference_document,
},
callback: (r) => {
if (r.message && r.message.length) {
frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message));
} else {
frm.set_df_property("fetch_from_parent", "hidden", 1);
if (
frm.doc.reference_document !== reference_document ||
frm.doc.document_type !== document_type ||
frm.doc.apply_to_all_doctypes ||
!frm.doc.istable
) {
return;
}
return set_parent_field_options(frm, r.message || []);
},
});
}
@@ -115,3 +135,12 @@ frappe.ui.form.on("Inventory Dimension", {
});
},
});
function set_parent_field_options(frm, fields) {
frm.set_df_property("fetch_from_parent", "options", ["", ...fields]);
frm.set_df_property("fetch_from_parent", "hidden", !fields.length);
if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) {
return frm.set_value("fetch_from_parent", "");
}
}

View File

@@ -219,6 +219,7 @@ class Item(Document):
self.validate_conversion_factor()
self.validate_item_type()
self.validate_naming_series()
self.validate_shelf_life()
self.check_for_active_boms()
self.fill_customer_code()
self.check_item_tax()
@@ -398,6 +399,19 @@ class Item(Document):
).format(self.item_code)
)
def validate_shelf_life(self):
if (
self.has_batch_no
and self.has_expiry_date
and self.create_new_batch
and cint(self.shelf_life_in_days) <= 0
):
frappe.throw(
_("{0} must be greater than zero.").format(
self.get_label_from_fieldname("shelf_life_in_days")
)
)
def clear_retain_sample(self):
if not self.has_batch_no:
self.retain_sample = False

View File

@@ -377,7 +377,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:44.554719",
"modified": "2026-09-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",
@@ -442,6 +442,11 @@
"submit": 1,
"write": 1
},
{
"read": 1,
"report": 1,
"role": "Manufacturing Manager"
},
{
"role": "Delivery Manager",
"select": 1

View File

@@ -18,6 +18,7 @@ from frappe.utils import cint, flt, get_datetime, get_link_to_form, getdate, new
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
from erpnext.stock.get_item_details import get_price_list_rate_for
from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty
@@ -209,14 +210,20 @@ class MaterialRequest(BuyingController):
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
self.validate_pp_qty()
self.set_buying_price_list()
if self.buying_price_list and not frappe.get_value("Price List", self.buying_price_list, "buying"):
def set_buying_price_list(self):
if not is_valid_buying_price_list(self.buying_price_list):
self.buying_price_list = None
if not self.buying_price_list:
buying_price_list = frappe.defaults.get_defaults().buying_price_list
if frappe.has_permission("Price List", "read", buying_price_list):
self.buying_price_list = buying_price_list
if self.buying_price_list:
return
default_price_list = frappe.defaults.get_defaults().buying_price_list
if is_valid_buying_price_list(default_price_list) and frappe.has_permission(
"Price List", "read", default_price_list
):
self.buying_price_list = default_price_list
def on_update(self):
if not self.is_new() and self.buying_price_list and self.has_value_changed("buying_price_list"):
@@ -507,6 +514,10 @@ class MaterialRequest(BuyingController):
doc.db_set("status", doc.status)
def is_valid_buying_price_list(price_list: str | None) -> bool:
return is_price_list_enabled(price_list) and bool(frappe.get_value("Price List", price_list, "buying"))
def update_completed_and_requested_qty(stock_entry, method):
if stock_entry.doctype == "Stock Entry":
material_request_map = {}

View File

@@ -41,6 +41,7 @@
},
{
"fetch_from": "item_code.item_name",
"fetch_if_empty": 1,
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
@@ -138,15 +139,16 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:08.352880",
"modified": "2026-09-09 13:04:53.623636",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packing Slip Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -86,7 +86,7 @@ class TestPickList(ERPNextTestSuite):
def test_pick_list_allocation_takes_advisory_gate(self):
if frappe.db.db_type != "postgres":
return
self.skipTest("advisory locks are a PostgreSQL feature")
item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item=item, to_warehouse="_Test Warehouse - _TC", qty=5, basic_rate=100)

View File

@@ -90,3 +90,7 @@ def get_price_list_details(price_list):
frappe.cache().hset("price_list_details", price_list, price_list_details)
return price_list_details or {}
def is_price_list_enabled(price_list: str | None) -> bool:
return bool(price_list) and bool(frappe.get_cached_value("Price List", price_list, "enabled"))

View File

@@ -200,10 +200,14 @@ def update_billing_percentage(
returned_qty = flt(item_wise_returned_qty.get(item.name))
returned_amount = flt(returned_qty) * flt(item.rate)
pending_amount = flt(item.amount) - returned_amount
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
pending_amount = flt(item.amount)
total_billable_amount = abs(flt(item.amount))
# When rejected qty is billable, its value is part of the billable base too
rejected_amount = 0.0
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount"))
pending_amount = flt(item.amount) + rejected_amount
total_billable_amount = abs(flt(item.amount) + rejected_amount)
if pending_amount > 0:
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
@@ -213,9 +217,7 @@ def update_billing_percentage(
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
total_amount = total_billed_amount
amount = item.amount
if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
amount += flt(item.rejected_qty * item.rate, item.precision("amount"))
amount = flt(item.amount) + rejected_amount
if adjust_incoming_rate:
adjusted_amt = 0.0

View File

@@ -617,6 +617,44 @@ class TestPurchaseReceipt(ERPNextTestSuite):
return_pr.cancel()
pr.cancel()
def test_per_billed_for_fully_rejected_receipt(self):
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
update_billing_percentage,
)
bill_rejected = frappe.db.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
try:
# Fully rejected receipt: accepted qty 0, whole qty in rejected warehouse
pr = make_purchase_receipt(
received_qty=10,
qty=0,
rejected_qty=10,
rate=9.5,
rejected_warehouse="_Test Warehouse 1 - _TC",
do_not_save=True,
)
pr.items[0].warehouse = ""
pr.submit()
# Bill the rejected qty (10 x 9.5) directly against the receipt item
pr.items[0].db_set("billed_amt", 95)
update_billing_percentage(pr)
pr.load_from_db()
# Billing the rejected qty must not push per_billed above 100
self.assertEqual(pr.per_billed, 100)
self.assertEqual(pr.status, "Completed")
pr.cancel()
finally:
frappe.db.set_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", bill_rejected
)
def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self):
from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse

View File

@@ -203,7 +203,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
def test_outward_batch_valuation_takes_transaction_advisory_lock(self):
if frappe.db.db_type != "postgres":
return
self.skipTest("advisory locks are a PostgreSQL feature")
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -1176,6 +1176,67 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
self.assertEqual(bundle_doc.docstatus, 0)
self.assertRaises(frappe.ValidationError, bundle_doc.submit)
@ERPNextTestSuite.change_settings("Stock Settings", {"do_not_use_batchwise_valuation": 0})
def test_amended_material_receipt_rate_after_batch_selection(self):
warehouse = "_Test Warehouse - _TC"
for valuation_method in ("FIFO", "Moving Average"):
with self.subTest(valuation_method=valuation_method):
item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"stock_uom": "Nos",
"valuation_method": valuation_method,
}
)
batches = [
frappe.get_doc(
{"doctype": "Batch", "item": item.name, "batch_id": f"{item.name}-{index}"}
)
.insert()
.name
for index in range(2)
]
for index, (batch, qty) in enumerate(((batches[0], 10), (batches[1], 10), (batches[0], 5))):
receipt = make_stock_entry(
item_code=item.name,
company="_Test Company",
to_warehouse=warehouse,
qty=qty,
rate=10,
batch_no=batch,
posting_date=add_days(today(), index - 2),
posting_time="10:00:00",
)
receipt.cancel()
amended = frappe.copy_doc(receipt, ignore_no_copy=False)
amended.amended_from = receipt.name
amended.docstatus = 0
row = amended.items[0]
row.batch_no = None
row.serial_and_batch_bundle = None
row.use_serial_batch_fields = 0
# The selector returns an unpriced bundle and copies its rate to the receipt row.
bundle = add_serial_batch_ledgers(
[{"batch_no": batches[1], "qty": 5}],
row.as_dict(),
amended.as_dict(),
warehouse,
)
row.serial_and_batch_bundle = bundle.name
row.basic_rate = bundle.avg_rate
amended.insert()
self.assertEqual(row.basic_rate, 10)
self.assertEqual(row.basic_amount, 50)
amended.submit()
ledger = frappe.get_doc("Stock Ledger Entry", {"voucher_no": amended.name, "is_cancelled": 0})
self.assertEqual(ledger.incoming_rate, 10)
self.assertEqual(ledger.stock_value_difference, 50)
self.assertEqual(ledger.stock_value, 250)
def test_reference_voucher_on_cancel(self):
"""
When a source document is cancelled, the reference voucher field

View File

@@ -26,17 +26,10 @@ class StockEntryGLComposer(BaseStockGLComposer):
doc = self.doc
gl_entries = super().compose(inventory_account_map)
if doc.purpose in ("Repack", "Manufacture"):
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
else:
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
divide_based_on = total_basic_amount
if doc.get("additional_costs") and not total_basic_amount:
divide_based_on = sum(item.qty for item in doc.get("items"))
incoming_items, basis, divide_based_on = doc.get_additional_cost_allocation()
item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
total_basic_amount, divide_based_on
incoming_items, basis, divide_based_on
)
if item_account_wise_additional_cost:
self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
@@ -183,24 +176,20 @@ class StockEntryGLComposer(BaseStockGLComposer):
)
def _build_additional_cost_per_item_account(
self, total_basic_amount: float, divide_based_on: float
self, incoming_items: list, basis: str, divide_based_on: float
) -> dict:
doc = self.doc
item_account_wise_additional_cost = {}
if not divide_based_on:
return item_account_wise_additional_cost
for t in doc.get("additional_costs"):
for d in doc.get("items"):
if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
continue
elif not d.t_warehouse:
continue
for t in self.doc.get("additional_costs"):
for d in incoming_items:
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
)
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
multiply_based_on = flt(d.get(basis))
entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on

View File

@@ -291,7 +291,7 @@ frappe.ui.form.on("Stock Entry", {
frm.trigger("toggle_weight_per_piece");
// only BOM-less rows are editable, and they cannot allocate a BOM percentage;
// read-only rows from a BOM still display their stored % of FG Cost
// read-only rows from a BOM still display their stored % of Component Cost
frm.fields_dict.items.grid.update_docfield_property("valuation_type", "options", [
"Valuation Rate",
"Manual",

View File

@@ -808,7 +808,7 @@ class StockEntry(StockController, SubcontractingInwardController):
def set_bomless_secondary_valuation_types(self):
"""Secondary rows without a BOM link choose their own costing: valuation rate or manual.
There is no percentage to allocate without a BOM row, so % of FG Cost is rejected."""
There is no percentage to allocate without a BOM row, so % of Component Cost is rejected."""
for d in self.get("items"):
if d.bom_secondary_item:
continue
@@ -819,10 +819,10 @@ class StockEntry(StockController, SubcontractingInwardController):
d.set_basic_rate_manually = 0
continue
if d.valuation_type == "% of FG Cost":
if d.valuation_type == "% of Component Cost":
frappe.throw(
_(
"Row #{0}: % of FG Cost needs a BOM secondary item. Choose Valuation Rate or Manual for {1}."
"Row #{0}: % of Component Cost needs a BOM secondary item. Choose Valuation Rate or Manual for {1}."
).format(d.idx, frappe.bold(d.item_code))
)
@@ -920,22 +920,28 @@ class StockEntry(StockController, SubcontractingInwardController):
self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs"))
if self.purpose in ("Repack", "Manufacture"):
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
else:
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
if not incoming_items_cost:
return
incoming_items, basis, total_basis = self.get_additional_cost_allocation()
for d in self.get("items"):
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
d.additional_cost = 0
continue
elif not d.t_warehouse:
d.additional_cost = 0
continue
d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs
d.additional_cost = 0
if not total_basis:
return
for d in incoming_items:
d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs
def get_additional_cost_allocation(self):
if self.purpose in ("Repack", "Manufacture"):
incoming_items = [d for d in self.get("items") if d.is_finished_item]
else:
incoming_items = [d for d in self.get("items") if d.t_warehouse]
total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items)
if total_basic_amount:
return incoming_items, "basic_amount", total_basic_amount
return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items)
def update_valuation_rate(self, reset_outgoing_rate=True):
for d in self.get("items"):

View File

@@ -1362,7 +1362,7 @@ class TestStockEntry(ERPNextTestSuite):
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
bom_doc.save()
@@ -1446,7 +1446,7 @@ class TestStockEntry(ERPNextTestSuite):
"secondary_item_type": "By-Product",
"qty": 1,
"cost_allocation_per": 10,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
bom_doc.save()
@@ -1660,7 +1660,7 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(fg_row.basic_amount, 830)
# there is no percentage to allocate without a BOM row
manual_row.valuation_type = "% of FG Cost"
manual_row.valuation_type = "% of Component Cost"
self.assertRaises(frappe.ValidationError, entry.save)
def test_valuation_rate_lookup_without_voucher_no(self):
@@ -2233,10 +2233,12 @@ class TestStockEntry(ERPNextTestSuite):
se.insert()
se.submit()
self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items])
self.check_gl_entries(
"Stock Entry",
se.name,
sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
)
def test_conversion_factor_change(self):
@@ -2282,6 +2284,184 @@ class TestStockEntry(ERPNextTestSuite):
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
def test_additional_cost_distribution_manufacture_zero_valued_items(self):
se = frappe.get_doc(
doctype="Stock Entry",
purpose="Manufacture",
additional_costs=[frappe._dict(base_amount=100)],
items=[
frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10),
frappe._dict(
item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1
),
frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"),
],
)
se.distribute_additional_costs()
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
def test_additional_cost_distribution_zero_valued_items(self):
se = frappe.get_doc(
doctype="Stock Entry",
purpose="Material Receipt",
additional_costs=[frappe._dict(base_amount=100)],
items=[
frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"),
frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"),
],
)
se.distribute_additional_costs()
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([40.0, 60.0], distributed_costs)
def test_additional_cost_gl_for_zero_valued_manufacture(self):
company = "_Test Company with perpetual inventory"
rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name
fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name
receipt = frappe.get_doc(
{
"doctype": "Stock Entry",
"purpose": "Material Receipt",
"stock_entry_type": "Material Receipt",
"posting_date": nowdate(),
"company": company,
"items": [
{
"item_code": rm,
"qty": 5,
"basic_rate": 0,
"uom": "Nos",
"t_warehouse": "Stores - TCP1",
"allow_zero_valuation_rate": 1,
"cost_center": "Main - TCP1",
}
],
}
)
receipt.insert()
receipt.submit()
se = frappe.get_doc(
{
"doctype": "Stock Entry",
"purpose": "Manufacture",
"stock_entry_type": "Manufacture",
"posting_date": nowdate(),
"company": company,
"items": [
{
"item_code": rm,
"qty": 5,
"uom": "Nos",
"s_warehouse": "Stores - TCP1",
"cost_center": "Main - TCP1",
},
{
"item_code": fg,
"qty": 5,
"uom": "Nos",
"t_warehouse": "Finished Goods - TCP1",
"is_finished_item": 1,
"cost_center": "Main - TCP1",
},
],
"additional_costs": [
{
"expense_account": "Miscellaneous Expenses - TCP1",
"amount": 500,
"description": "freight",
}
],
}
)
se.insert()
se.submit()
self.assertEqual(500.0, se.items[1].additional_cost)
self.check_gl_entries(
"Stock Entry",
se.name,
sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]),
)
def test_additional_cost_gl_matches_valuation_split(self):
company = "_Test Company with perpetual inventory"
cost_center = "_Test Additional Cost CC - TCP1"
if not frappe.db.exists("Cost Center", cost_center):
frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Additional Cost CC",
"company": company,
"is_group": 0,
"parent_cost_center": "_Test Company with perpetual inventory - TCP1",
}
).insert()
uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}]
item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name
uoms[1]["conversion_factor"] = 3
item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name
se = frappe.get_doc(
{
"doctype": "Stock Entry",
"purpose": "Material Receipt",
"stock_entry_type": "Material Receipt",
"posting_date": nowdate(),
"company": company,
"items": [
{
"item_code": item_a,
"qty": 1,
"basic_rate": 0,
"uom": "Box",
"conversion_factor": 2,
"t_warehouse": "Stores - TCP1",
"allow_zero_valuation_rate": 1,
"cost_center": "Main - TCP1",
},
{
"item_code": item_b,
"qty": 1,
"basic_rate": 0,
"uom": "Box",
"conversion_factor": 3,
"t_warehouse": "Stores - TCP1",
"allow_zero_valuation_rate": 1,
"cost_center": cost_center,
},
],
"additional_costs": [
{
"expense_account": "Miscellaneous Expenses - TCP1",
"amount": 100,
"description": "misc",
}
],
}
)
se.insert()
se.submit()
self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items])
expense_by_cost_center = frappe.get_all(
"GL Entry",
filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"},
fields=["cost_center", "credit"],
)
self.assertEqual(
{"Main - TCP1": 40.0, cost_center: 60.0},
{d.cost_center: d.credit for d in expense_by_cost_center},
)
def test_additional_cost_distribution_non_manufacture(self):
se = frappe.get_doc(
doctype="Stock Entry",
@@ -3478,7 +3658,7 @@ class TestStockEntry(ERPNextTestSuite):
"qty": 5,
"cost_allocation_per": 25,
"process_loss_per": 0,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
bom.insert()
@@ -3540,7 +3720,7 @@ class TestStockEntry(ERPNextTestSuite):
"qty": 5,
"cost_allocation_per": 0,
"process_loss_per": 0,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
bom.insert()
@@ -3597,7 +3777,7 @@ class TestStockEntry(ERPNextTestSuite):
"qty": 5,
"cost_allocation_per": 25,
"process_loss_per": 0,
"valuation_type": "% of FG Cost",
"valuation_type": "% of Component Cost",
},
)
bom.insert()
@@ -4264,11 +4444,6 @@ class TestStockEntryCoverage(ERPNextTestSuite):
# ── validate_source_stock_entry ────────────────────────────────────────────
def test_validate_source_stock_entry_skips_when_no_source(self):
se = frappe.new_doc("Stock Entry")
se.source_stock_entry = None
se.validate_source_stock_entry() # must not raise
def test_validate_source_stock_entry_throws_on_work_order_mismatch(self):
source_se = make_stock_entry(
item_code="_Test Item",
@@ -4298,62 +4473,6 @@ class TestStockEntryCoverage(ERPNextTestSuite):
se.work_order = "WO-SAME-001"
se.validate_source_stock_entry() # must not raise
# ── validate_job_card_fg_item ──────────────────────────────────────────────
def test_validate_job_card_fg_item_skips_when_no_job_card(self):
se = frappe.new_doc("Stock Entry")
se.job_card = None
se.validate_job_card_fg_item() # must not raise
def test_validate_job_card_fg_item_throws_when_fg_item_mismatches(self):
wrong_fg = make_item("_JC Wrong FG Item", {"is_stock_item": 1}).name
jc_name = frappe.db.get_value("Job Card", {"docstatus": 1, "finished_good": ("!=", "")})
if not jc_name:
return # skip if no suitable job card in test data
jc = frappe.db.get_value("Job Card", jc_name, ["finished_good"], as_dict=1)
if jc.finished_good == wrong_fg:
return # skip if the wrong_fg happens to match
se = frappe.new_doc("Stock Entry")
se.job_card = jc_name
se.append("items", {"item_code": wrong_fg, "is_finished_item": 1, "qty": 1})
self.assertRaises(frappe.ValidationError, se.validate_job_card_fg_item)
# ── validate_job_card_item ─────────────────────────────────────────────────
def test_validate_job_card_item_skips_when_no_job_card(self):
se = frappe.new_doc("Stock Entry")
se.job_card = None
se.validate_job_card_item() # must not raise
def test_validate_job_card_item_skips_for_manufacture_purpose(self):
se = frappe.new_doc("Stock Entry")
se.job_card = "SOME-JC-001"
se.purpose = "Manufacture"
se.validate_job_card_item() # must not raise even with a job card set
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
def test_validate_job_card_item_throws_when_job_card_item_ref_missing(self):
jc_name = frappe.db.get_value("Job Card", {"docstatus": 1})
if not jc_name:
return # skip if no job cards in test data
se = frappe.new_doc("Stock Entry")
se.job_card = jc_name
se.purpose = "Material Transfer for Manufacture"
se.append(
"items",
{
"item_code": "_Test Item",
"s_warehouse": "_Test Warehouse - _TC",
"qty": 1,
"job_card_item": None,
},
)
self.assertRaises(frappe.ValidationError, se.validate_job_card_item)
# ── get_available_materials ────────────────────────────────────────────────
def test_get_available_materials_tracks_transferred_qty(self):

View File

@@ -693,7 +693,7 @@
"fieldname": "valuation_type",
"fieldtype": "Select",
"label": "Valuation Type",
"options": "\nValuation Rate\n% of FG Cost\nManual",
"options": "\nValuation Rate\n% of Component Cost\nManual",
"read_only_depends_on": "eval:!doc.secondary_item_type || doc.bom_secondary_item"
}
],

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