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
198 changed files with 5220 additions and 5158 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

@@ -818,7 +818,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
)
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
batch_no = create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
item = frappe.get_doc("Item", "_BATCH ITEM")
se = make_stock_entry(
@@ -826,10 +826,12 @@ class TestPOSInvoice(POSInvoiceTestMixin):
item_code="_BATCH ITEM",
qty=2,
basic_rate=100,
batch_no=batch_no,
batch_no="TestBatch 01",
)
pos_inv1 = create_pos_invoice(item=item.name, rate=300, qty=1, do_not_submit=1, batch_no=batch_no)
pos_inv1 = create_pos_invoice(
item=item.name, rate=300, qty=1, do_not_submit=1, batch_no="TestBatch 01"
)
pos_inv1.append(
"payments",
{"mode_of_payment": "Cash", "amount": 300},
@@ -847,7 +849,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
"voucher_no": pos_inv2.name,
"qty": 2,
"avg_rate": 300,
"batches": frappe._dict({batch_no: 2}),
"batches": frappe._dict({"TestBatch 01": 2}),
"type_of_transaction": "Outward",
"company": pos_inv2.company,
}
@@ -923,7 +925,6 @@ class TestPOSInvoice(POSInvoiceTestMixin):
self.assertRaises(frappe.ValidationError, pos_inv.submit)
@ERPNextTestSuite.change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_bundle_stock_availability_validation(self):
from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle

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

@@ -36,7 +36,6 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
from erpnext.stock.tests.test_utils import StockTestMixin
from erpnext.tests.utils import ERPNextTestSuite
@@ -2644,8 +2643,25 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
batch_no = "BATCH-PI-BNU-TPRBI-0001"
serial_nos = ["SNU-PI-TPRSI-0001", "SNU-PI-TPRSI-0002", "SNU-PI-TPRSI-0003"]
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, [batch_no], create=True)[0]
serial_nos = SerialBatchIdentity("Serial No").resolve(serial_item, serial_nos, create=True)
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_no,
"item": batch_item,
}
).insert()
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
frappe.get_doc(
{
"doctype": "Serial No",
"item_code": serial_item,
"serial_no": serial_no,
"company": "_Test Company",
}
).insert()
pi = make_purchase_invoice(
item_code=batch_item,
@@ -3045,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

@@ -2,10 +2,10 @@
# License: GNU General Public License v3. See license.txt
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class PurchaseInvoiceItem(SerialBatchReference):
class PurchaseInvoiceItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

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

@@ -4,12 +4,12 @@
import frappe
from frappe import _
from frappe.model.document import Document
from erpnext.assets.doctype.asset.depreciation import get_disposal_account_and_cost_center
from erpnext.stock.serial_batch_display import SerialBatchReference
class SalesInvoiceItem(SerialBatchReference):
class SalesInvoiceItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

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

@@ -2,10 +2,10 @@
# For license information, please see license.txt
# import frappe
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class AssetCapitalizationStockItem(SerialBatchReference):
class AssetCapitalizationStockItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

View File

@@ -2,10 +2,10 @@
# For license information, please see license.txt
# import frappe
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class AssetRepairConsumedItem(SerialBatchReference):
class AssetRepairConsumedItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

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

@@ -2,10 +2,10 @@
# License: GNU General Public License v3. See license.txt
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class PurchaseReceiptItemSupplied(SerialBatchReference):
class PurchaseReceiptItemSupplied(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

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

@@ -596,15 +596,13 @@ def get_batch_no(doctype: str, txt: str, searchfield: str, start: int, page_len:
if filters.get("is_inward"):
filtered_batches.extend(get_empty_batches(filters, start, page_len, filtered_batches, txt))
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
labels = SerialBatchIdentity("Batch").labels([row[0] for row in filtered_batches])
return [(row[0], labels.get(row[0], row[0]), *row[1:]) for row in filtered_batches]
return filtered_batches
def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None):
query_filter = {"item": filters.get("item_code"), "disabled": 0}
or_filters = {"batch_id": ("like", f"%{txt}%"), "name": txt} if txt else None
if txt:
query_filter["name"] = ("like", f"%{txt}%")
exclude_batches = [batch[0] for batch in filtered_batches] if filtered_batches else []
if exclude_batches:
@@ -614,7 +612,6 @@ def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None)
"Batch",
fields=["name", "batch_qty"],
filters=query_filter,
or_filters=or_filters,
limit_start=start,
limit_page_length=page_len,
as_list=1,
@@ -690,7 +687,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
query = query.select(batch_table[field])
if txt:
txt_condition = batch_table.batch_id.like(f"%{txt}%")
txt_condition = batch_table.name.like(f"%{txt}%")
for field in [*searchfields, "name"]:
txt_condition |= batch_table[field].like(f"%{txt}%")
@@ -756,7 +753,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
bundle_query = bundle_query.select(batch_table[field])
if txt:
txt_condition = batch_table.batch_id.like(f"%{txt}%")
txt_condition = batch_table.name.like(f"%{txt}%")
for field in [*searchfields, "name"]:
txt_condition |= batch_table[field].like(f"%{txt}%")
@@ -1021,11 +1018,11 @@ def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page
batch = frappe.qb.DocType("Batch")
query = (
frappe.qb.from_(batch)
.select(batch.name, batch.batch_id, batch.item)
.select(batch.batch_id)
.where(
(batch.disabled == 0)
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
& batch.batch_id.like(f"%{txt}%")
& batch.name.like(f"%{txt}%")
)
)

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

@@ -21,7 +21,6 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
from erpnext.stock.utils import get_incoming_rate
@@ -434,10 +433,9 @@ class SubcontractingController(StockController):
consumed_bundles = voucher_bundle_data.get(bundle_key, frappe._dict())
if consumed_bundles.serial_nos:
consumed_serials = set(consumed_bundles.serial_nos)
self.available_materials[key]["serial_no"] = [
sn for sn in self.available_materials[key]["serial_no"] if sn not in consumed_serials
]
self.available_materials[key]["serial_no"] = list(
set(self.available_materials[key]["serial_no"]) - set(consumed_bundles.serial_nos)
)
if consumed_bundles.batch_nos:
for batch_no, qty in consumed_bundles.batch_nos.items():
@@ -451,10 +449,9 @@ class SubcontractingController(StockController):
from erpnext.deprecation_dumpster import deprecation_warning
deprecation_warning("unknown", "v16", "No instructions.")
consumed_serials = set(get_serial_nos(row.serial_no))
self.available_materials[key]["serial_no"] = [
sn for sn in self.available_materials[key]["serial_no"] if sn not in consumed_serials
]
self.available_materials[key]["serial_no"] = list(
set(self.available_materials[key]["serial_no"]) - set(get_serial_nos(row.serial_no))
)
# Will be deprecated in v16
if row.batch_no and not consumed_bundles.batch_nos:
@@ -534,12 +531,6 @@ class SubcontractingController(StockController):
self.__set_alternative_item_details(row)
serial_numbers = SerialBatchIdentity("Serial No").labels(
[sn for details in self.available_materials.values() for sn in details.serial_no]
)
for details in self.available_materials.values():
details.serial_no.sort(key=lambda sn: serial_numbers.get(sn) or sn)
self.__transferred_items = copy.deepcopy(self.available_materials)
self.__update_consumed_materials("Subcontracting Receipt")
@@ -691,7 +682,7 @@ class SubcontractingController(StockController):
return available_batches
def __get_serial_nos_for_bundle(self, qty, key):
available_sns = self.available_materials[key]["serial_no"][0 : cint(qty)]
available_sns = sorted(self.available_materials[key]["serial_no"])[0 : cint(qty)]
serial_nos = []
for serial_no in available_sns:

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

@@ -995,9 +995,9 @@ class TestSubcontractingController(ERPNextTestSuite):
if value.get(field):
data = value.get(field)
if field == "serial_no":
self.assertCountEqual(data, transferred_detais.get(field))
else:
self.assertEqual(data, transferred_detais.get(field))
data = sorted(data)
self.assertEqual(data, transferred_detais.get(field))
scr2 = make_subcontracting_receipt(sco.name)
scr2.save()
@@ -1010,9 +1010,9 @@ class TestSubcontractingController(ERPNextTestSuite):
if value.get(field):
data = value.get(field)
if field == "serial_no":
self.assertCountEqual(data, transferred_detais.get(field))
else:
self.assertEqual(data, transferred_detais.get(field))
data = sorted(data)
self.assertEqual(data, transferred_detais.get(field))
def test_subcontracting_with_same_components_different_fg_with_serial_batch_fields(self):
"""
@@ -1338,7 +1338,7 @@ def make_stock_transfer_entry(**args):
batches = defaultdict(float)
if item_details and item_details.serial_no:
serial_nos = item_details.serial_no[0 : cint(row.qty)]
item_details.serial_no = item_details.serial_no[cint(row.qty) :]
item_details.serial_no = list(set(item_details.serial_no) - set(serial_nos))
if item_details and item_details.batch_no:
for batch_no, batch_qty in item_details.batch_no.items():

View File

@@ -72,10 +72,7 @@ doctype_list_js = {
page_js = {"print": "public/js/print.js"}
extend_doctype_class = {
"Address": "erpnext.accounts.custom.address.ERPNextAddress",
"Data Import": "erpnext.stock.serial_batch_import.SerialBatchDataImport",
}
extend_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}
override_whitelisted_methods = {"frappe.www.contact.send_message": "erpnext.templates.utils.send_message"}
@@ -333,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 = {
@@ -387,7 +386,6 @@ pre_submit_validation_doctypes = [
doc_events = {
"*": {
"before_print": "erpnext.stock.serial_batch_display.set_serial_number_labels",
"validate": [
"erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
"erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job",

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@
# For license information, please see license.txt
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class MaintenanceScheduleDetail(SerialBatchReference):
class MaintenanceScheduleDetail(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

View File

@@ -2,10 +2,10 @@
# For license information, please see license.txt
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class MaintenanceScheduleItem(SerialBatchReference):
class MaintenanceScheduleItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

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",
},
)
@@ -1860,7 +1860,7 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(len(entries), 5)
for entry in entries:
self.assertEqual(flt(entry.qty), 10.0)
self.assertTrue(frappe.db.get_value("Batch", entry.batch_no, "batch_id").startswith("BS-ROD-PC-"))
self.assertTrue(entry.batch_no.startswith("BS-ROD-PC-"))
self.assertEqual(frappe.db.get_value("Batch", entry.batch_no, "parent_batch"), parent_batch)
manufacture_entry.reload()
@@ -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

@@ -36,7 +36,6 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.stock_entry.stock_entry import OperationsNotCompleteError
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
from erpnext.stock.utils import get_bin
from erpnext.tests.utils import ERPNextTestSuite
@@ -1972,7 +1971,6 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 0})
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
@@ -2165,8 +2163,6 @@ class TestWorkOrder(ERPNextTestSuite):
)
transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list)
transferred_ste_doc.items[0].serial_and_batch_bundle = None
transferred_ste_doc.items[0].use_serial_batch_fields = 1
transferred_ste_doc.submit()
# First Manufacture stock entry
@@ -3774,12 +3770,8 @@ class TestWorkOrder(ERPNextTestSuite):
# Pre-generate two sets of FG serial numbers
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
fg_serials_1 = SerialBatchIdentity("Serial No").resolve(
fg_item, [make_autoname(series) for _ in range(3)], create=True
)
fg_serials_2 = SerialBatchIdentity("Serial No").resolve(
fg_item, [make_autoname(series) for _ in range(3)], create=True
)
fg_serials_1 = [make_autoname(series) for _ in range(3)]
fg_serials_2 = [make_autoname(series) for _ in range(3)]
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
@@ -5411,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,
},
)
@@ -5460,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

@@ -830,30 +830,58 @@ class WorkOrder(Document):
serial_nos = []
if item_details.serial_no_series:
serial_nos = get_available_serial_nos(
item_details.serial_no_series, self.qty, self.production_item
)
serial_nos = get_available_serial_nos(item_details.serial_no_series, self.qty)
if not serial_nos:
return
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
fields = [
"name",
"serial_no",
"creation",
"modified",
"owner",
"modified_by",
"company",
"item_code",
"item_name",
"description",
"status",
"work_order",
"batch_no",
]
groups = {}
for index, number in enumerate(serial_nos, 1):
batch_no = batches[0] if batches and self.batch_size else None
groups.setdefault(batch_no, []).append(number)
if batch_no and index % self.batch_size == 0:
batches.pop(0)
serial_nos_details = []
index = 0
for serial_no in serial_nos:
index += 1
batch_no = None
if batches and self.batch_size:
batch_no = batches[0]
for batch_no, numbers in groups.items():
SerialBatchIdentity("Serial No").resolve(
self.production_item,
numbers,
create=True,
defaults={"company": self.company, "work_order": self.name, "batch_no": batch_no},
if index % self.batch_size == 0:
batches.remove(batch_no)
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
frappe.session.user,
frappe.session.user,
self.company,
self.production_item,
item_details.item_name,
item_details.description,
"Inactive",
self.name,
batch_no,
)
)
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
def validate_cancel(self):
if self.status == "Stopped":
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))

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

@@ -262,7 +262,6 @@ erpnext.patches.v15_0.rename_subcontracting_fields
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
erpnext.patches.v16_0.convert_commission_rate_to_percent
erpnext.patches.v16_0.convert_hide_currency_symbol_to_check
erpnext.patches.v17_0.separate_serial_batch_identity
[post_model_sync]
erpnext.patches.v15_0.rename_gross_purchase_amount_to_net_purchase_amount
@@ -521,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

@@ -1,20 +0,0 @@
import frappe
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
def execute():
checked = []
for doctype in ("Serial No", "Batch"):
identity = SerialBatchIdentity(doctype)
if not identity.has_constraint():
identity.validate_existing_numbers()
checked.append(doctype)
previous = frappe.flags.serial_batch_preflight
try:
frappe.flags.serial_batch_preflight = checked
for doctype in ("Serial No", "Batch"):
frappe.reload_doc("stock", "doctype", frappe.scrub(doctype), force=True)
finally:
frappe.flags.serial_batch_preflight = previous

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

@@ -6,8 +6,6 @@ import "./utils/party";
import "./utils/draft_link_guard";
import "./controllers/stock_controller";
import "./utils/serial_no_batch_selector";
import "./utils/serial_batch_input";
import "./utils/serial_batch_display";
import "./utils/serial_batch_inline_editor";
import "./payment/payments";
import "./templates/visual_plant_floor_template.html";

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

@@ -55,16 +55,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
return;
}
this.scan_api_call(input, async (r) => {
let data = r && r.message;
if (data?.candidates) {
data = await this.select_scan_match(data.candidates);
if (!data) {
this.clean_up();
resolve();
return;
}
}
this.scan_api_call(input, (r) => {
const data = r && r.message;
if (
!data ||
Object.keys(data).length === 0 ||
@@ -103,98 +95,29 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
});
}
select_scan_match(candidates) {
const item_codes = [...new Set(candidates.map((candidate) => candidate.item_code))];
if (item_codes.length <= 1) {
return Promise.resolve(this.get_scan_match(candidates, item_codes[0]));
}
return new Promise((resolve) => {
let selected = false;
const dialog = new frappe.ui.Dialog({
title: __("Select Item"),
size: "small",
fields: [
{
fieldname: "item_code",
label: __("Item"),
fieldtype: "Link",
options: "Item",
only_select: 1,
get_query: () => ({ filters: { name: ["in", item_codes] } }),
filter_description: __("Items matching the scanned number"),
reqd: 1,
},
],
primary_action_label: __("Select"),
primary_action: ({ item_code }) => {
const match = this.get_scan_match(candidates, item_code);
if (!match) return;
selected = true;
dialog.hide();
resolve(match);
},
onhide: () => {
if (!selected) resolve(null);
},
});
dialog.show();
});
}
get_scan_match(candidates, item_code) {
const matches = candidates.filter((candidate) => candidate.item_code === item_code);
// Keep the serial reference when the same item's barcode or batch number also matches.
return (
matches.find((match) => match.serial_no) || matches.find((match) => match.batch_no) || matches[0]
);
}
scan_api_call(input, callback, item_code) {
scan_api_call(input, callback) {
frappe
.call({
method: this.scan_api,
args: {
search_value: input,
allow_multiple: true,
ctx: {
item_code,
set_warehouse: this.frm.doc.set_warehouse,
company: this.frm.doc.company,
},
},
})
.then((r) => {
for (const match of r.message?.candidates || [r.message || {}]) {
if (match.serial_no && match.serial_number)
frappe.utils.add_link_title("Serial No", match.serial_no, match.serial_number);
if (match.batch_no && match.batch_number)
frappe.utils.add_link_title("Batch", match.batch_no, match.batch_number);
}
callback(r);
});
}
update_table(data) {
if (data.has_serial_no && data.batch_no && !data.serial_no) {
frappe.msgprint(__("Please scan a serial number for Item {0}", [data.item_code]));
return Promise.reject();
}
return new Promise((resolve, reject) => {
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
frappe.flags.trigger_from_barcode_scanner = true;
const { item_code, barcode, batch_no, serial_no, uom, default_warehouse } = data;
if (
serial_no &&
(this.frm.doc[this.items_table_name] || []).some(
(row) => row.item_code === item_code && this.is_duplicate_serial_no(row, serial_no)
)
) {
this.clean_up();
reject();
return;
}
let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, default_warehouse);
const is_new_row = !row?.item_code;
if (!row) {
@@ -212,6 +135,12 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
this.frm.has_items = false;
}
if (this.is_duplicate_serial_no(row, serial_no)) {
this.clean_up();
reject();
return;
}
frappe.run_serially([
() => this.set_selector_trigger_flag(data),
() => this.set_barcode(row, barcode),
@@ -251,18 +180,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
set_item(row, item_code, barcode, batch_no, serial_no) {
return new Promise((resolve) => {
const increment = async (value = 1) => {
const existing = erpnext.serial_batch_input.is_pending(row, this.serial_no_field)
? ""
: row[this.serial_no_field];
const item_data = this.get_scanned_item_values(
row,
item_code,
batch_no,
serial_no ? this.merge_serial_nos(existing, serial_no) : null
);
const item_data = { item_code: item_code, use_serial_batch_fields: 1.0 };
frappe.flags.trigger_from_barcode_scanner = true;
item_data[this.qty_field] =
Number((row.item_code && row[this.qty_field]) || 0) + Number(value);
item_data[this.qty_field] = Number(row[this.qty_field] || 0) + Number(value);
await frappe.model.set_value(row.doctype, row.name, item_data);
return value;
};
@@ -279,28 +199,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
});
}
get_scanned_item_values(row, item_code, batch_no, serial_no) {
// Item selection must receive the scanned references before it can auto-pick stock.
const values = { item_code, use_serial_batch_fields: 1 };
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) {
const numbers = serial_no
.split("\n")
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
.join("\n");
values[this.serial_no_field] = this.merge_serial_nos(row[this.serial_no_field], numbers);
erpnext.serial_batch_input.mark(row, this.serial_no_field, values[this.serial_no_field]);
} else {
values[this.serial_no_field] = serial_no;
}
}
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
values[this.batch_no_field] = batch_no;
erpnext.serial_batch_input.clear(row, this.batch_no_field);
}
return values;
}
prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no) {
var me = this;
this.dialog = new frappe.ui.Dialog({
@@ -308,23 +206,19 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
fields: me.get_fields_for_dialog(row, item_code, barcode, batch_no, serial_no),
});
this.dialog.set_primary_action(__("Update"), async () => {
const item_data = this.get_scanned_item_values(
row,
item_code,
this.dialog.get_value("batch_no"),
this.dialog.get_value("serial_no")
);
this.dialog.set_primary_action(__("Update"), () => {
const item_data = { item_code: item_code };
item_data[this.qty_field] = this.dialog.get_value("scanned_qty");
item_data["has_item_scanned"] = 1;
this.remaining_qty =
flt(this.dialog.get_value("qty")) - flt(this.dialog.get_value("scanned_qty"));
await frappe.model.set_value(row.doctype, row.name, item_data);
frappe.model.set_value(row.doctype, row.name, item_data);
await frappe.run_serially([
frappe.run_serially([
() => this.set_batch_no(row, this.dialog.get_value("batch_no")),
() => this.set_barcode(row, this.dialog.get_value("barcode")),
() => this.set_serial_no(row, this.dialog.get_value("serial_no")),
() => this.add_child_for_remaining_qty(row),
() => this.clean_up(),
]);
@@ -351,15 +245,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
if (e.target.value) {
this.scan_api_call(
e.target.value,
async (r) => {
if (r.message?.candidates)
r.message = await this.select_scan_match(r.message.candidates);
if (r.message) this.update_dialog_values(item_code, r);
},
item_code
);
this.scan_api_call(e.target.value, (r) => {
if (r.message) {
this.update_dialog_values(item_code, r);
}
});
}
},
},
@@ -392,7 +282,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
fields.push({
fieldtype: "Link",
fieldname: "batch_no",
options: "Batch",
options: "Batch No",
label: __("Batch No"),
default: batch_no,
read_only: 1,
@@ -407,17 +297,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
label: __("Serial Nos"),
default: serial_no,
read_only: 1,
hidden: 1,
});
}
if (serial_no) {
fields.push({
fieldtype: "Small Text",
fieldname: "serial_numbers",
label: __("Serial Nos"),
default: frappe.utils.get_link_title("Serial No", serial_no) || serial_no,
read_only: 1,
});
}
@@ -437,7 +316,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
update_dialog_values(scanned_item, r) {
const { item_code, barcode, batch_no, serial_no, serial_number } = r.message;
const { item_code, barcode, batch_no, serial_no } = r.message;
this.dialog.set_value("barcode_scanner", "");
if (
@@ -452,10 +331,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
this.validate_duplicate_serial_no(serial_no);
let serial_nos = this.dialog.get_value("serial_no") + "\n" + serial_no;
this.dialog.set_value("serial_no", serial_nos);
this.dialog.set_value(
"serial_numbers",
this.dialog.get_value("serial_numbers") + "\n" + (serial_number || serial_no)
);
}
let qty = flt(this.dialog.get_value("scanned_qty")) + 1.0;
@@ -507,24 +382,18 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
async set_serial_no(row, serial_no) {
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) {
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
const merged = this.merge_serial_nos(row[this.serial_no_field], number);
erpnext.serial_batch_input.mark(row, this.serial_no_field, merged);
await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, merged);
return;
const existing_serial_nos = row[this.serial_no_field];
let new_serial_nos = "";
if (!!existing_serial_nos) {
new_serial_nos = existing_serial_nos + "\n" + serial_no;
} else {
new_serial_nos = serial_no;
}
const new_serial_nos = this.merge_serial_nos(row[this.serial_no_field], serial_no);
await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos);
}
}
merge_serial_nos(existing, added) {
return [...new Set(`${existing || ""}\n${added || ""}`.split("\n").map((id) => id.trim()))]
.filter(Boolean)
.join("\n");
}
async set_barcode_uom(row, uom) {
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
// barcode uom must not overwrite the row's own uom.
@@ -535,7 +404,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
async set_batch_no(row, batch_no) {
erpnext.serial_batch_input.clear(row, this.batch_no_field);
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no);
}
@@ -569,18 +437,10 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
is_duplicate_serial_no(row, serial_no) {
const physical_number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
const pending_duplicate =
erpnext.serial_batch_input.is_pending(row, this.serial_no_field) &&
row[this.serial_no_field]
?.split("\n")
.some((number) => number.toUpperCase() === physical_number?.toUpperCase());
const is_duplicate =
serial_no && (pending_duplicate || row[this.serial_no_field]?.split("\n").includes(serial_no));
const is_duplicate = row[this.serial_no_field]?.includes(serial_no);
if (is_duplicate) {
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
this.show_alert(__("Serial No {0} is already added", [number]), "orange");
this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
}
return is_duplicate;
}
@@ -601,12 +461,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
const matching_row = (row) => {
const item_match = row.item_code == item_code;
const batch_match =
!row[this.batch_no_field] ||
(erpnext.serial_batch_input.is_pending(row, this.batch_no_field)
? row[this.batch_no_field].toUpperCase() ===
frappe.utils.get_link_title("Batch", batch_no)?.toUpperCase()
: row[this.batch_no_field] === batch_no);
const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no;
const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom;
const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]);
const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]);

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

@@ -1,19 +0,0 @@
// Reports export physical numbers and retain a separate ID field for each link.
frappe.form.formatters.SerialBatchNumber = (value, df, options, doc) => {
if (!value) return "";
const labels = String(value).split("\n");
const ids = String(doc?.[df.reference_field] || "").split("\n");
return labels
.map((label, index) => {
if (
!ids[index] ||
options?.for_print ||
options?.only_value ||
!frappe.model.can_read(df.options)
) {
return frappe.utils.escape_html(label);
}
return frappe.form.formatters.Link(ids[index], df, { ...options, label }, doc);
})
.join("<br>");
};

View File

@@ -469,32 +469,19 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
$td.data("editing", 1);
let name = $td.data("name");
let pending_index = $td.data("pending-index");
let entry =
pending_index != null
? this.pending.new_entries[pending_index]
: this.last_entries.find((row) => row.name === name);
let number_field = opts.field === "serial_no" ? "serial_number" : "batch_number";
let current =
(pending_index == null && this.pending.updates[name]?.[opts.field]) || entry?.[opts.field] || "";
let current = $td.text().trim();
$td.empty().addClass("sbie-input-cell").css("cursor", "default");
this.wrapper.find(".sbie-table").css("overflow", "visible");
let control = this.make_row_link_control($td, {
options: opts.options,
fieldname: "sbie_edit_link",
placeholder: (!current && entry?.[number_field]) || opts.placeholder,
placeholder: opts.placeholder,
get_query: opts.get_query,
onchange: () => {
let value = control.get_value();
if (value && value !== current) {
if (pending_index != null) {
entry[opts.field] = value;
delete entry[number_field];
this.frm.dirty();
} else {
this.update_entry(name, { [opts.field]: value });
}
this.update_entry(name, { [opts.field]: value });
this.refresh_view();
}
},
@@ -649,9 +636,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
for (const row of rows) {
p.new_entries.push({
serial_no: row.serial_no || "",
serial_number: row.serial_number,
batch_no: row.batch_no || "",
batch_number: row.batch_number,
qty: Math.abs(flt(row.qty)) || 1,
});
}
@@ -707,37 +692,22 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
dialog.show();
}
get_entry_number(entry, field) {
let update = this.pending.updates[entry.name] || {};
let name = update[field] || entry[field];
let is_serial = field === "serial_no";
return (
(!update[field] && entry[is_serial ? "serial_number" : "batch_number"]) ||
frappe.utils.get_link_title(is_serial ? "Serial No" : "Batch", name) ||
name ||
""
);
}
get_active_server_row(field, value) {
let p = this.pending;
if (p.delete_all) return null;
return this.last_entries.find(
(d) => this.get_entry_number(d, field) === value && !p.deleted.some((x) => x.name === d.name)
);
return this.last_entries.find((d) => d[field] === value && !p.deleted.some((x) => x.name === d.name));
}
get_known_identifiers() {
let p = this.pending;
let field = cint(this.item.has_serial_no) ? "serial_no" : "batch_no";
let known = new Set(p.new_entries.map((d) => this.get_entry_number(d, field)));
let known = new Set(p.new_entries.map((d) => d.serial_no || d.batch_no));
if (!p.delete_all) {
let deleted = new Set(p.deleted.map((d) => d.name));
for (const d of this.last_entries) {
if (!deleted.has(d.name)) {
known.add(this.get_entry_number(d, field));
known.add(d.serial_no || d.batch_no);
}
}
}
@@ -757,9 +727,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
return false;
}
p.new_entries.push({ serial_number: value, qty: 1 });
p.new_entries.push({ serial_no: value, batch_no: "", qty: 1 });
} else {
let existing = p.new_entries.find((d) => this.get_entry_number(d, "batch_no") === value);
let existing = p.new_entries.find((d) => d.batch_no === value);
let server_row = this.get_active_server_row("batch_no", value);
if (existing) {
existing.qty = flt(existing.qty) + 1;
@@ -768,7 +738,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
let current = update && update.qty != null ? flt(update.qty) : Math.abs(flt(server_row.qty));
this.update_entry(server_row.name, { qty: current + 1 });
} else {
p.new_entries.push({ batch_number: value, qty: 1 });
p.new_entries.push({ serial_no: "", batch_no: value, qty: 1 });
}
}
@@ -818,7 +788,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
let added = 0;
for (const serial_no of serial_nos) {
if (known.has(serial_no)) continue;
p.new_entries.push({ serial_number: serial_no, qty: 1 });
p.new_entries.push({ serial_no: serial_no, batch_no: "", qty: 1 });
added++;
}
@@ -965,8 +935,8 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
.map((d, i) => {
let update = p.updates[d.name] || {};
let qty = update.qty != null ? flt(update.qty) : Math.abs(flt(d.qty));
let batch_no = this.esc(this.get_entry_number(d, "batch_no"));
let serial_no = this.esc(this.get_entry_number(d, "serial_no"));
let batch_no = this.esc(update.batch_no || d.batch_no || "");
let serial_no = this.esc(update.serial_no || d.serial_no || "");
let name = this.esc(d.name);
return `<tr data-name="${name}">
@@ -987,12 +957,8 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
)}" style="cursor: pointer;">${batch_no}</td>`
: ""
}
<td class="${
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
}" style="text-align: right;">${
!(d.serial_no || d.serial_number) && show_batch
? this.get_qty_input(d, qty)
: this.format_float(qty)
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
!d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty)
}</td>
</tr>`;
})
@@ -1009,26 +975,10 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
<td style="text-align: center;">
<input type="checkbox" class="sbie-check" data-pending-index="${index}"></td>
<td style="text-align: center;">${base_count + index + 1}</td>
${
show_serial
? `<td class="sbie-serial-cell" data-pending-index="${index}" title="${__(
"Click to change Serial No"
)}" style="cursor: pointer;">${this.esc(
this.get_entry_number(d, "serial_no")
)}</td>`
: ""
}
${
show_batch
? `<td class="sbie-batch-cell" data-pending-index="${index}" title="${__(
"Click to change Batch No"
)}" style="cursor: pointer;">${this.esc(this.get_entry_number(d, "batch_no"))}</td>`
: ""
}
<td class="${
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
}" style="text-align: right;">${
!(d.serial_no || d.serial_number) && show_batch
${show_serial ? `<td>${this.esc(d.serial_no || "")}</td>` : ""}
${show_batch ? `<td>${this.esc(d.batch_no || "")}</td>` : ""}
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
!d.serial_no && show_batch
? this.get_pending_qty_input(d, index)
: this.format_float(d.qty)
}</td>

View File

@@ -1,282 +0,0 @@
// Physical input remains pending until the transaction is saved.
const registered_forms = new Set();
const pending_values = new WeakMap();
const serial_list_fields = new Set(["serial_no", "rejected_serial_no", "current_serial_no"]);
const with_serial_numbers = (BaseControl) =>
class extends BaseControl {
number_context() {
return this.serial_batch_context || { frm: this.frm, row: this.doc };
}
is_serial_list() {
const { frm, row } = this.number_context();
return (
frm &&
this.df.parent !== "Serial No" &&
serial_list_fields.has(this.df.fieldname) &&
(row?.item_code || row?.rm_item_code)
);
}
bind_change_event() {
if (!this.frm || !serial_list_fields.has(this.df.fieldname) || this.df.parent === "Serial No")
return super.bind_change_event();
this.$input.on("change", (event) =>
this.parse_validate_and_set_in_model(this.get_input_value(), event)
);
this.$input.on("input", () => this.number_context().frm.dirty());
}
async parse_validate_and_set_in_model(value, event) {
const revision = (this.number_revision = (this.number_revision || 0) + 1);
if (!this.is_serial_list() || !event) {
return super.parse_validate_and_set_in_model(value, event);
}
const context = this.number_context();
if (
context.row.parenttype &&
frappe.meta.has_field(context.row.doctype, "serial_and_batch_bundle")
) {
const numbers = split_physical_numbers(value);
await set_pending_number(context, this.df.fieldname, numbers.join("\n"));
return;
}
const { frm, row } = this.number_context();
const item_code = row.item_code || row.rm_item_code;
const pending = (async () => {
const numbers = (value || "")
.split(/[,\n]/)
.map((number) => number.trim())
.filter(Boolean);
const result = numbers.length
? await frappe.xcall("erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers", {
item_code,
serial_numbers: numbers,
})
: { serial_nos: [] };
const ids = result.serial_nos;
if (revision !== this.number_revision || item_code !== (row.item_code || row.rm_item_code))
return;
ids.forEach((id, index) => frappe.utils.add_link_title("Serial No", id, numbers[index]));
return super.parse_validate_and_set_in_model(ids.join("\n"), event);
})();
track_number_request(frm, pending);
try {
return await pending;
} catch (error) {
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
throw error;
} finally {
frm.serial_number_requests.delete(pending);
}
}
serial_number_text(value) {
const { row } = this.number_context();
if (erpnext.serial_batch_input.is_pending(row, this.df.fieldname)) return value || "";
return (value || "")
.split("\n")
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
.join("\n");
}
async load_serial_titles(value) {
if (erpnext.serial_batch_input.is_pending(this.number_context().row, this.df.fieldname)) return;
const missing = (value || "")
.split("\n")
.filter((id) => id && !frappe.utils.get_link_title("Serial No", id));
if (!missing.length) return;
if (this.title_request_value !== value) {
this.title_request_value = value;
this.title_request = frappe.xcall(
"erpnext.stock.serial_batch_identity.get_serial_batch_labels",
{
doctype: "Serial No",
names: missing,
}
);
}
const labels = await this.title_request;
Object.entries(labels).forEach(([id, label]) =>
frappe.utils.add_link_title("Serial No", id, label)
);
}
set_formatted_input(value) {
if (!this.is_serial_list()) return super.set_formatted_input(value);
super.set_formatted_input(this.serial_number_text(value));
this.load_serial_titles(value).then(() => {
if (this.get_model_value() === value && !this.$input?.is(":focus")) {
super.set_formatted_input(this.serial_number_text(value));
}
});
}
set_disp_area(value) {
if (!this.is_serial_list()) return super.set_disp_area(value);
if (this.disp_area) $(this.disp_area).text(this.serial_number_text(value));
this.load_serial_titles(value).then(() => {
if (this.disp_area && this.get_model_value() === value) {
$(this.disp_area).text(this.serial_number_text(value));
}
});
}
};
frappe.ui.form.ControlSmallText = with_serial_numbers(frappe.ui.form.ControlSmallText);
frappe.ui.form.ControlText = with_serial_numbers(frappe.ui.form.ControlText);
frappe.ui.form.ControlLongText = with_serial_numbers(frappe.ui.form.ControlLongText);
frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink {
async parse_validate_and_set_in_model(value, event, label) {
const revision = (this.number_revision = (this.number_revision || 0) + 1);
const doctype = this.get_options();
const { frm, row } = this.serial_batch_context || { frm: this.frm, row: this.doc };
const item_code = row?.item_code || row?.rm_item_code || row?.item;
if (
!frm ||
!item_code ||
!["Serial No", "Batch"].includes(doctype) ||
(!event && label === undefined)
) {
return super.parse_validate_and_set_in_model(value, event, label);
}
if (
doctype === "Batch" &&
this.df.fieldname === "batch_no" &&
row.parenttype &&
frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")
) {
await set_pending_number({ frm, row }, "batch_no", (label ?? this.get_label_value()).trim());
return;
}
if (label !== undefined) erpnext.serial_batch_input.clear(row, this.df.fieldname);
// Autocomplete supplies the selected physical label; change/blur supplies typed text.
const number = (label ?? this.get_label_value()).trim();
const pending = (async () => {
let name = "";
if (number) {
const serial = doctype === "Serial No";
const result = await frappe.xcall(
"erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers",
{
item_code,
[serial ? "serial_numbers" : "batch_numbers"]: [number],
}
);
name = result[serial ? "serial_nos" : "batch_nos"][0];
}
if (
revision !== this.number_revision ||
item_code !== (row?.item_code || row?.rm_item_code || row?.item)
)
return;
return super.parse_validate_and_set_in_model(name, event, number);
})();
track_number_request(frm, pending);
try {
return await pending;
} catch (error) {
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
throw error;
} finally {
frm.serial_number_requests.delete(pending);
}
}
set_formatted_input(value) {
super.set_formatted_input(value);
const { row } = this.serial_batch_context || { row: this.doc };
if (this.df.fieldname === "batch_no" && erpnext.serial_batch_input.is_pending(row, "batch_no")) {
this.$input?.val(value);
}
}
};
function split_physical_numbers(value) {
return (value || "")
.split(/[,\n]/)
.map((number) => number.trim())
.filter(Boolean);
}
async function set_pending_number({ frm, row }, field, value) {
erpnext.serial_batch_input.mark(row, field, value);
row[field] = value;
frm.dirty();
frm.refresh_field(row.parentfield || field);
const values = {};
if (frappe.meta.has_field(row.doctype, "use_serial_batch_fields")) values.use_serial_batch_fields = 1;
if (frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")) values.serial_and_batch_bundle = "";
const pending = (async () => {
await frappe.model.set_value(row.doctype, row.name, values);
const numbers = split_physical_numbers(value);
if (field === "serial_no" && numbers.length && !frm.doc.is_return && row.serial_no === value) {
await frappe.model.set_value(
row.doctype,
row.name,
"qty",
numbers.length / (row.conversion_factor || 1)
);
}
})();
track_number_request(frm, pending);
try {
await pending;
} finally {
frm.serial_number_requests.delete(pending);
}
}
function track_number_request(frm, pending) {
if (!registered_forms.has(frm.doctype)) {
registered_forms.add(frm.doctype);
const wait = async (form) => {
await Promise.all([...(form.serial_number_requests || [])]);
for (const row of frappe.model.get_all_docs(form.doc)) {
for (const field of [...(row.__serial_batch_input || [])]) {
erpnext.serial_batch_input.is_pending(row, field);
}
}
};
frappe.ui.form.on(frm.doctype, {
validate: wait,
before_save: wait,
after_save(form) {
for (const row of frappe.model.get_all_docs(form.doc)) {
delete row.__serial_batch_input;
pending_values.delete(row);
}
},
});
}
frm.serial_number_requests ||= new Set();
frm.serial_number_requests.add(pending);
}
erpnext.serial_batch_input = {
mark(row, field, value) {
row.__serial_batch_input = [...new Set([...(row.__serial_batch_input || []), field])];
const inputs = pending_values.get(row) || {};
inputs[field] = value;
pending_values.set(row, inputs);
},
is_pending(row, field) {
if (!row?.__serial_batch_input?.includes(field)) return false;
const inputs = pending_values.get(row);
if (inputs && field in inputs && inputs[field] !== row[field]) {
this.clear(row, field);
return false;
}
return true;
},
clear(row, field) {
if (!row?.__serial_batch_input) return;
row.__serial_batch_input = row.__serial_batch_input.filter((name) => name !== field);
if (!row.__serial_batch_input.length) delete row.__serial_batch_input;
const inputs = pending_values.get(row);
if (inputs) delete inputs[field];
},
};

View File

@@ -54,19 +54,26 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
qty = Math.abs(qty);
if (qty > 0) {
this.dialog.set_value("qty", qty).then(async () => {
this.dialog.set_value("qty", qty).then(() => {
if (this.item.serial_no && !this.item.serial_and_batch_bundle) {
await this.set_data(
this.item.serial_no
.split("\n")
.filter(Boolean)
.map((serial_no) => ({ serial_no, batch_no: this.item.batch_no, qty: 1 }))
);
let serial_nos = this.item.serial_no.split("\n");
if (serial_nos.length > 1) {
serial_nos.forEach((serial_no) => {
this.dialog.fields_dict.entries.df.data.push({
serial_no: serial_no,
batch_no: this.item.batch_no,
});
});
} else {
this.dialog.set_value("scan_serial_no", this.item.serial_no);
}
frappe.model.set_value(this.item.doctype, this.item.name, "serial_no", "");
} else if (this.item.batch_no && !this.item.serial_and_batch_bundle) {
await this.set_data([{ batch_no: this.item.batch_no, qty }]);
this.dialog.set_value("scan_batch_no", this.item.batch_no);
frappe.model.set_value(this.item.doctype, this.item.name, "batch_no", "");
}
this.dialog.fields_dict.entries.grid.refresh();
});
}
}
@@ -329,10 +336,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
item_code: this.item.item_code,
serial_nos: upload_serial_nos,
},
callback: async (r) => {
callback: (r) => {
if (r.message) {
this.dialog.fields_dict.entries.df.data = [];
await this.set_data(r.message);
this.set_data(r.message);
this.update_bundle_entries();
}
},
@@ -515,18 +522,6 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
hidden: 1,
});
if (this.item.type_of_transaction === "Inward") {
for (const field of fields) {
if (!["serial_no", "batch_no"].includes(field.fieldname)) continue;
const reference = field.fieldname;
field.fieldtype = "Data";
field.fieldname = reference.replace("_no", "_number");
field.change = function () {
this.doc[reference] = null;
};
}
}
return fields;
}
@@ -576,8 +571,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
},
callback: (r) => {
if (r.message) {
this.dialog.fields_dict.entries.df.data = [];
this.set_data(r.message);
this.dialog.fields_dict.entries.df.data = r.message;
this.dialog.fields_dict.entries.grid.refresh();
}
},
});
@@ -589,45 +584,24 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
this.dialog.set_value("enter_manually", 0);
if (this.item.type_of_transaction === "Inward") {
const entries = this.dialog.fields_dict.entries.df.data;
if (
scan_serial_no &&
entries.some((row) => row.serial_number?.toUpperCase() === scan_serial_no.toUpperCase())
) {
frappe.throw(__("Serial No {0} already exists", [scan_serial_no]));
}
if (scan_serial_no || scan_batch_no) {
const batch =
!scan_serial_no &&
entries.find((row) => row.batch_number?.toUpperCase() === scan_batch_no.toUpperCase());
if (batch) batch.qty = flt(batch.qty) + 1;
else entries.push({ serial_number: scan_serial_no, batch_number: scan_batch_no, qty: 1 });
this.dialog.set_value("scan_serial_no", "");
this.dialog.set_value("scan_batch_no", "");
this.dialog.fields_dict.entries.grid.refresh();
}
return;
}
if (scan_serial_no || scan_batch_no) {
frappe.call({
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.resolve_scanned_serial_batch_numbers",
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_serial_batch_no_exists",
args: {
item_code: this.item.item_code,
type_of_transaction: this.item.type_of_transaction,
serial_no: scan_serial_no,
batch_no: scan_batch_no,
},
callback: (r) => {
this.update_serial_batch_no(r.message);
this.update_serial_batch_no();
},
});
}
}
update_serial_batch_no(result) {
const scan_serial_no = result.serial_nos?.[0];
const scan_batch_no = result.batch_nos?.[0];
update_serial_batch_no() {
const { scan_serial_no, scan_batch_no } = this.dialog.get_values();
if (scan_serial_no) {
let existing_row = this.dialog.fields_dict.entries.df.data.filter((d) => {
@@ -798,22 +772,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
}
}
async set_data(data) {
if (this.item.type_of_transaction === "Inward") {
for (const [field, doctype] of [
["serial_no", "Serial No"],
["batch_no", "Batch"],
]) {
const names = data.map((row) => row[field]).filter(Boolean);
const labels = names.length
? await frappe.xcall("erpnext.stock.serial_batch_identity.get_serial_batch_labels", {
doctype,
names,
})
: {};
for (const row of data) row[field.replace("_no", "_number")] ||= labels[row[field]];
}
}
set_data(data) {
data.forEach((d) => {
d.qty = Math.abs(d.qty);
d.name = d.child_row || d.name;

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

@@ -2,10 +2,10 @@
# License: GNU General Public License v3. See license.txt
from erpnext.stock.serial_batch_display import SerialBatchReference
from frappe.model.document import Document
class InstallationNoteItem(SerialBatchReference):
class InstallationNoteItem(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.

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
@@ -3453,8 +3495,8 @@ class TestSalesOrder(ERPNextTestSuite):
serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle)
batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys())
self.assertCountEqual(sre_serial_nos, serial_nos_in_bundle)
self.assertCountEqual(sre_batch_nos, batches_in_bundle)
self.assertEqual(sre_serial_nos, serial_nos_in_bundle)
self.assertEqual(sre_batch_nos, batches_in_bundle)
dn.items[0].qty = 5
dn.save()
@@ -3495,8 +3537,8 @@ class TestSalesOrder(ERPNextTestSuite):
serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle)
batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys())
self.assertCountEqual(sre_serial_nos, serial_nos_in_bundle)
self.assertCountEqual(sre_batch_nos, batches_in_bundle)
self.assertEqual(sre_serial_nos, serial_nos_in_bundle)
self.assertEqual(sre_batch_nos, batches_in_bundle)
si.items[0].qty = 5
si.save()

View File

@@ -16,23 +16,16 @@ from erpnext.stock.utils import scan_barcode
def search_by_term(search_term, warehouse, price_list):
result = scan_barcode(search_term, allow_multiple=True)
if not result or result.get("warehouse"):
return
matches = result.get("candidates", [result])
return {
"items": [get_scanned_item(match, warehouse, price_list) for match in matches],
"requires_selection": len(matches) > 1,
"is_scan": True,
}
result = search_for_serial_or_batch_or_barcode_number(search_term) or {}
def get_scanned_item(result, warehouse, price_list):
item_code = result["item_code"]
item_code = result.get("item_code", search_term)
serial_no = result.get("serial_no", "")
batch_no = result.get("batch_no", "")
barcode = result.get("barcode", "")
if not result:
return
item_doc = frappe.get_doc("Item", item_code)
if not item_doc:
@@ -116,7 +109,7 @@ def get_scanned_item(result, warehouse, price_list):
}
)
return item
return {"items": [item]}
def filter_result_items(result, pos_profile):
@@ -278,10 +271,8 @@ def get_items(
@frappe.whitelist()
def search_for_serial_or_batch_or_barcode_number(
search_value: str, item_code: str | None = None, allow_multiple: bool = False
) -> dict:
return scan_barcode(search_value, {"item_code": item_code}, allow_multiple=allow_multiple)
def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str, str | None]:
return scan_barcode(search_value)
def get_conditions(search_term, item=None):

View File

@@ -199,7 +199,6 @@ erpnext.PointOfSale.ItemDetails = class {
parent: this.$form_container.find(`.${fieldname}-control`),
render_input: true,
});
this[`${fieldname}_control`].serial_batch_context = { frm: this.events.get_frm(), row: item };
this[`${fieldname}_control`].set_value(item[fieldname]);
});

View File

@@ -74,34 +74,11 @@ erpnext.PointOfSale.ItemSelector = class {
const price_list = (doc && doc.selling_price_list) || this.price_list;
let { item_group, pos_profile } = this;
const cache_key = JSON.stringify([
pos_profile,
price_list,
item_group,
start,
page_length,
search_term,
]);
this.items_cache ||= new Map();
const scanned = this.barcode_search_pending;
this.barcode_search_pending = false;
if (!scanned && this.items_cache.has(cache_key)) {
return $.Deferred()
.resolve({ message: this.items_cache.get(cache_key) })
.promise();
}
return frappe
.call({
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
freeze: true,
args: { start, page_length, price_list, item_group, search_term, pos_profile },
})
.then((response) => {
if (!scanned && !response.message?.is_scan && response.message?.items?.length) {
this.items_cache.set(cache_key, response.message);
}
return response;
});
return frappe.call({
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
freeze: true,
args: { start, page_length, price_list, item_group, search_term, pos_profile },
});
}
render_item_list(items) {
@@ -370,7 +347,6 @@ erpnext.PointOfSale.ItemSelector = class {
this.search_field.set_focus();
this.set_search_value(sScancode);
this.barcode_scanned = true;
this.barcode_search_pending = true;
}
},
});
@@ -459,14 +435,32 @@ erpnext.PointOfSale.ItemSelector = class {
filter_items({ search_term = "" } = {}) {
this.start_item_loading_animation();
const selling_price_list = this.events.get_frm().doc.selling_price_list;
if (search_term) {
search_term = search_term.toLowerCase();
// memoize
this.search_index = this.search_index || {};
this.search_index[selling_price_list] = this.search_index[selling_price_list] || {};
if (this.search_index[selling_price_list][search_term]) {
const items = this.search_index[selling_price_list][search_term];
this.items = items;
this.render_item_list(items);
this.auto_add_item &&
this.search_field.$input[0].value &&
this.items.length == 1 &&
this.add_filtered_item_to_cart();
return;
}
}
this.get_items({ search_term })
.then(({ message }) => {
const { items, requires_selection } = message;
if (requires_selection) {
frappe.show_alert({
message: __("Select the item that matches the scanned number."),
indicator: "blue",
});
// eslint-disable-next-line no-unused-vars
const { items, serial_no, batch_no, barcode } = message;
if (search_term && !barcode) {
this.search_index[selling_price_list][search_term] = items;
}
this.items = items;
this.render_item_list(items);

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"

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