mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-21 12:27:14 +00:00
Compare commits
66 Commits
codex/seri
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4a473f3df | ||
|
|
fe25746feb | ||
|
|
f5f956c4dd | ||
|
|
fe9d6e5a57 | ||
|
|
bec627c3eb | ||
|
|
bee358ea25 | ||
|
|
000dcfc23d | ||
|
|
825d24f406 | ||
|
|
e6f431a8d6 | ||
|
|
5dfd21cce6 | ||
|
|
6f5f2cfce1 | ||
|
|
142976a829 | ||
|
|
1dd0b7dc9e | ||
|
|
b481083ff0 | ||
|
|
be8208e7cb | ||
|
|
4671d1a665 | ||
|
|
5f216c5d55 | ||
|
|
4d95a240bb | ||
|
|
86821ac6ee | ||
|
|
bc2fa03730 | ||
|
|
e6b8e90ad9 | ||
|
|
d82c35aae9 | ||
|
|
b1e99a70ac | ||
|
|
d5e63b8a9e | ||
|
|
3be0c7801a | ||
|
|
33a066d568 | ||
|
|
cf6aeddc29 | ||
|
|
3f422f8e0d | ||
|
|
f130c64530 | ||
|
|
681bd2734f | ||
|
|
31205c4114 | ||
|
|
503a80f2c9 | ||
|
|
3a8bd852d5 | ||
|
|
a8ec43bcf8 | ||
|
|
e2b2940452 | ||
|
|
3761eb8cbe | ||
|
|
fd492100b0 | ||
|
|
28bd1332de | ||
|
|
4b23cee2ea | ||
|
|
bdf124ab06 | ||
|
|
b2bdeaa672 | ||
|
|
bcade8f0ba | ||
|
|
4c32acf300 | ||
|
|
db9e93306a | ||
|
|
e93ca84398 | ||
|
|
afd93cf867 | ||
|
|
51fb261b6b | ||
|
|
f8c2f3440b | ||
|
|
991cea5ae2 | ||
|
|
11d2847d3e | ||
|
|
e0d6d797d7 | ||
|
|
d5a9d158f9 | ||
|
|
fa89552d10 | ||
|
|
cee9f4949a | ||
|
|
218e7927ff | ||
|
|
36a4dfe797 | ||
|
|
e825bb2f74 | ||
|
|
f864333afa | ||
|
|
c412310eb5 | ||
|
|
1728d1b0f5 | ||
|
|
79fc039092 | ||
|
|
467f54162f | ||
|
|
f60c349794 | ||
|
|
cadc0ca86d | ||
|
|
2f572b1624 | ||
|
|
13031d6d5d |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from frappe import _
|
||||
|
||||
|
||||
def get_data():
|
||||
return {
|
||||
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
|
||||
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
{% } %}
|
||||
{% } %}
|
||||
|
||||
@@ -114,6 +114,7 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
139
erpnext/buying/test_utils.py
Normal file
139
erpnext/buying/test_utils.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}%")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
18
erpnext/patches/v16_0/recalculate_holiday_list_totals.py
Normal file
18
erpnext/patches/v16_0/recalculate_holiday_list_totals.py
Normal 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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -727,7 +727,7 @@
|
||||
<button class="btn btn-primary mes-btn-start" data-job-card="{{ frappe.utils.escape_html(slot.name) }}">
|
||||
{{ __("Start Job") }} →
|
||||
</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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>");
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -58,9 +58,10 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "total_holidays",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Total Holidays",
|
||||
"precision": "1",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user