mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-17 10:36:31 +00:00
Merge pull request #59072 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -17,7 +17,8 @@ import json
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.contacts.doctype.address.address import get_address_display
|
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
|
from erpnext.controllers.accounts_controller import AccountsController
|
||||||
|
|
||||||
@@ -140,6 +141,31 @@ class Dunning(AccountsController):
|
|||||||
)
|
)
|
||||||
row.dunning_level = len(past_dunnings) + 1
|
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):
|
def on_cancel(self):
|
||||||
super().on_cancel()
|
super().on_cancel()
|
||||||
self.ignore_linked_doctypes = [
|
self.ignore_linked_doctypes = [
|
||||||
@@ -154,6 +180,7 @@ class Dunning(AccountsController):
|
|||||||
"Unreconcile Payment Entries",
|
"Unreconcile Payment Entries",
|
||||||
"Payment Ledger Entry",
|
"Payment Ledger Entry",
|
||||||
"Serial and Batch Bundle",
|
"Serial and Batch Bundle",
|
||||||
|
"Payment Entry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -252,11 +279,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
|
|||||||
if has_outstanding:
|
if has_outstanding:
|
||||||
break
|
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
|
def update_dunnings_linked_to_payment(payment_entry):
|
||||||
dunning.save()
|
"""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):
|
def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
|||||||
create_dunning as create_dunning_from_sales_invoice,
|
create_dunning as create_dunning_from_sales_invoice,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
||||||
|
create_sales_invoice,
|
||||||
create_sales_invoice_against_cost_center,
|
create_sales_invoice_against_cost_center,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,123 @@ class TestDunning(FrappeTestCase):
|
|||||||
dunning.reload()
|
dunning.reload()
|
||||||
self.assertEqual(dunning.status, "Resolved")
|
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)
|
||||||
|
|
||||||
|
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),
|
||||||
|
customer="_Test Customer USD",
|
||||||
|
currency="USD",
|
||||||
|
conversion_rate=50,
|
||||||
|
rate=100,
|
||||||
|
debit_to="_Test Receivable USD - _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):
|
def test_fetch_overdue_payments(self):
|
||||||
"""
|
"""
|
||||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
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 () {
|
primary_action: function () {
|
||||||
var data = d.get_values();
|
var data = d.get_values();
|
||||||
|
data.company = frm.doc.company;
|
||||||
|
|
||||||
frappe.call({
|
frappe.call({
|
||||||
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",
|
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"modified": "2019-05-30 19:08:21.199759",
|
"modified": "2026-09-09 17:04:59.512294",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Invoice Discounting",
|
"name": "Invoice Discounting",
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "System Manager",
|
"role": "Accounts Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"submit": 1,
|
"submit": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
|||||||
@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_invoices(filters):
|
def get_invoices(filters):
|
||||||
filters = frappe._dict(json.loads(filters))
|
filters = frappe._dict(json.loads(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)
|
||||||
|
|
||||||
cond = []
|
cond = []
|
||||||
if filters.customer:
|
if filters.customer:
|
||||||
cond.append("customer=%(customer)s")
|
cond.append("customer=%(customer)s")
|
||||||
|
|||||||
@@ -3,6 +3,142 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
from erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates import (
|
||||||
|
execute as backfill_not_applicable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestItemTaxTemplate(unittest.TestCase):
|
class TestItemTaxTemplate(unittest.TestCase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestGermanNotApplicableBackfill(FrappeTestCase):
|
||||||
|
"""Run the `not_applicable` backfill patch against a seeded German company.
|
||||||
|
|
||||||
|
The company is created from the shipped German defaults, so the templates the
|
||||||
|
patch has to recognise are the ones a real site got. Each test resets the flag
|
||||||
|
to its pre-patch state (`not_applicable = 0`) and runs the patch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TITLES = ("19 %", "7 %", "0%")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.company = "_Test German Item Tax Templates"
|
||||||
|
if not frappe.db.exists("Company", cls.company):
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Company",
|
||||||
|
"company_name": cls.company,
|
||||||
|
"abbr": "_TGITT",
|
||||||
|
"country": "Germany",
|
||||||
|
"default_currency": "EUR",
|
||||||
|
"create_chart_of_accounts_based_on": "Standard Template",
|
||||||
|
"chart_of_accounts": "Standard",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
cls.templates = {
|
||||||
|
title: frappe.db.get_value("Item Tax Template", {"company": cls.company, "title": title}, "name")
|
||||||
|
for title in cls.TITLES
|
||||||
|
}
|
||||||
|
assert all(cls.templates.values()), f"German defaults not seeded: {cls.templates}"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
frappe.db.savepoint("before_backfill_test")
|
||||||
|
self.addCleanup(frappe.db.rollback, save_point="before_backfill_test")
|
||||||
|
self.seeded_flags = self.flagged_rows()
|
||||||
|
# every default template ships not-applicable rows, otherwise the patch
|
||||||
|
# would be tested against effectively empty data
|
||||||
|
for title in self.TITLES:
|
||||||
|
self.assertTrue(self.seeded_flags[title], f"no not-applicable rows seeded in {title}")
|
||||||
|
|
||||||
|
def flagged_rows(self, title=None) -> dict[str, set]:
|
||||||
|
"""Detail rows currently marked as not applicable, per template title."""
|
||||||
|
return {
|
||||||
|
t: {
|
||||||
|
d.name
|
||||||
|
for d in frappe.get_all(
|
||||||
|
"Item Tax Template Detail",
|
||||||
|
filters={"parent": name, "not_applicable": 1},
|
||||||
|
fields=["name"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for t, name in self.templates.items()
|
||||||
|
if title in (None, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_flags(self):
|
||||||
|
"""Restore the pre-patch state: zero rate, no flag."""
|
||||||
|
for name in self.templates.values():
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Item Tax Template Detail",
|
||||||
|
{"parent": name},
|
||||||
|
"not_applicable",
|
||||||
|
0,
|
||||||
|
update_modified=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.flagged_rows(), {t: set() for t in self.TITLES})
|
||||||
|
|
||||||
|
def add_zero_rate_row(self, title, account_name, account_number):
|
||||||
|
"""Add a user-defined zero-rate row, as a customised site would have."""
|
||||||
|
like_account = frappe.db.get_value(
|
||||||
|
"Account", {"company": self.company, "account_name": "Umsatzsteuer 19 %"}, "name"
|
||||||
|
)
|
||||||
|
account = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Account",
|
||||||
|
"company": self.company,
|
||||||
|
"account_name": account_name,
|
||||||
|
"account_number": account_number,
|
||||||
|
"account_type": "Tax",
|
||||||
|
"parent_account": frappe.db.get_value("Account", like_account, "parent_account"),
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
template = frappe.get_doc("Item Tax Template", self.templates[title])
|
||||||
|
template.append("taxes", {"tax_type": account.name, "tax_rate": 0})
|
||||||
|
template.save()
|
||||||
|
|
||||||
|
def test_backfills_unmodified_defaults(self):
|
||||||
|
self.clear_flags()
|
||||||
|
backfill_not_applicable()
|
||||||
|
self.assertEqual(self.flagged_rows(), self.seeded_flags)
|
||||||
|
|
||||||
|
def test_keeps_customised_template_untouched(self):
|
||||||
|
self.clear_flags()
|
||||||
|
self.add_zero_rate_row("19 %", "Sonstige Umsatzsteuer", "9998")
|
||||||
|
backfill_not_applicable()
|
||||||
|
|
||||||
|
self.assertEqual(self.flagged_rows("19 %"), {"19 %": set()})
|
||||||
|
self.assertEqual(self.flagged_rows("7 %"), {"7 %": self.seeded_flags["7 %"]})
|
||||||
|
|
||||||
|
def test_keeps_duplicate_account_name_untouched(self):
|
||||||
|
"""A numbered account can share `account_name` with a default one.
|
||||||
|
|
||||||
|
Its identifier collapses onto the default's, so only the row count tells
|
||||||
|
the customised template apart from an untouched one.
|
||||||
|
"""
|
||||||
|
self.clear_flags()
|
||||||
|
self.add_zero_rate_row("7 %", "Umsatzsteuer 19 %", "9999")
|
||||||
|
backfill_not_applicable()
|
||||||
|
|
||||||
|
self.assertEqual(self.flagged_rows("7 %"), {"7 %": set()})
|
||||||
|
self.assertEqual(self.flagged_rows("19 %"), {"19 %": self.seeded_flags["19 %"]})
|
||||||
|
|
||||||
|
def test_rerun_changes_nothing(self):
|
||||||
|
def snapshot():
|
||||||
|
return frappe.get_all(
|
||||||
|
"Item Tax Template Detail",
|
||||||
|
filters={"parent": ("in", tuple(self.templates.values()))},
|
||||||
|
fields=["name", "not_applicable", "tax_rate", "modified"],
|
||||||
|
order_by="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
before = snapshot()
|
||||||
|
backfill_not_applicable()
|
||||||
|
self.assertEqual(snapshot(), before)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import date
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
@@ -122,8 +123,14 @@ class PaymentEntry(AccountsController):
|
|||||||
self.update_payment_schedule()
|
self.update_payment_schedule()
|
||||||
self.make_gl_entries()
|
self.make_gl_entries()
|
||||||
self.update_outstanding_amounts()
|
self.update_outstanding_amounts()
|
||||||
|
self.update_linked_dunnings()
|
||||||
self.set_status()
|
self.set_status()
|
||||||
|
|
||||||
|
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):
|
def validate_for_repost(self):
|
||||||
validate_docs_for_voucher_types(["Payment Entry"])
|
validate_docs_for_voucher_types(["Payment Entry"])
|
||||||
validate_docs_for_deferred_accounting([self.name], [])
|
validate_docs_for_deferred_accounting([self.name], [])
|
||||||
@@ -225,6 +232,7 @@ class PaymentEntry(AccountsController):
|
|||||||
self.update_payment_schedule(cancel=1)
|
self.update_payment_schedule(cancel=1)
|
||||||
self.make_gl_entries(cancel=1)
|
self.make_gl_entries(cancel=1)
|
||||||
self.update_outstanding_amounts()
|
self.update_outstanding_amounts()
|
||||||
|
self.update_linked_dunnings()
|
||||||
self.delink_advance_entry_references()
|
self.delink_advance_entry_references()
|
||||||
self.set_status()
|
self.set_status()
|
||||||
|
|
||||||
@@ -2891,15 +2899,15 @@ def get_reference_details(
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_payment_entry(
|
def get_payment_entry(
|
||||||
dt,
|
dt: str,
|
||||||
dn,
|
dn: str,
|
||||||
party_amount=None,
|
party_amount: int | float | None = None,
|
||||||
bank_account=None,
|
bank_account: str | None = None,
|
||||||
bank_amount=None,
|
bank_amount: int | float | None = None,
|
||||||
party_type=None,
|
party_type: str | None = None,
|
||||||
payment_type=None,
|
payment_type: str | None = None,
|
||||||
reference_date=None,
|
reference_date: str | date | None = None,
|
||||||
created_from_payment_request=False,
|
created_from_payment_request: bool | None = False,
|
||||||
):
|
):
|
||||||
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
||||||
|
|
||||||
@@ -3000,7 +3008,7 @@ def get_payment_entry(
|
|||||||
pe.append("references", reference)
|
pe.append("references", reference)
|
||||||
else:
|
else:
|
||||||
if dt == "Dunning":
|
if dt == "Dunning":
|
||||||
for overdue_payment in doc.overdue_payments:
|
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
|
||||||
pe.append(
|
pe.append(
|
||||||
"references",
|
"references",
|
||||||
{
|
{
|
||||||
@@ -3008,21 +3016,23 @@ def get_payment_entry(
|
|||||||
"reference_name": overdue_payment.sales_invoice,
|
"reference_name": overdue_payment.sales_invoice,
|
||||||
"payment_term": overdue_payment.payment_term,
|
"payment_term": overdue_payment.payment_term,
|
||||||
"due_date": overdue_payment.due_date,
|
"due_date": overdue_payment.due_date,
|
||||||
"total_amount": overdue_payment.outstanding,
|
"total_amount": outstanding,
|
||||||
"outstanding_amount": overdue_payment.outstanding,
|
"outstanding_amount": outstanding,
|
||||||
"allocated_amount": overdue_payment.outstanding,
|
"allocated_amount": outstanding,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
pe.append(
|
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
|
||||||
"deductions",
|
pe.append(
|
||||||
{
|
"deductions",
|
||||||
"account": doc.income_account,
|
{
|
||||||
"cost_center": doc.cost_center,
|
"account": doc.income_account,
|
||||||
"amount": -1 * doc.dunning_amount,
|
"cost_center": doc.cost_center,
|
||||||
"description": _("Interest and/or dunning fee"),
|
"amount": -1 * unpaid_dunning_amount,
|
||||||
},
|
"description": _("Interest and/or dunning fee"),
|
||||||
)
|
"dunning": doc.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
pe.append(
|
pe.append(
|
||||||
"references",
|
"references",
|
||||||
@@ -3304,8 +3314,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
|
|||||||
grand_total = doc.rounded_total or doc.grand_total
|
grand_total = doc.rounded_total or doc.grand_total
|
||||||
outstanding_amount = doc.outstanding_amount
|
outstanding_amount = doc.outstanding_amount
|
||||||
elif dt == "Dunning":
|
elif dt == "Dunning":
|
||||||
grand_total = doc.grand_total
|
# only what is left to collect, the totals on the dunning are the ones it was raised with
|
||||||
outstanding_amount = doc.grand_total
|
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:
|
else:
|
||||||
if party_account_currency == doc.company_currency:
|
if party_account_currency == doc.company_currency:
|
||||||
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"amount",
|
"amount",
|
||||||
"column_break_2",
|
"column_break_2",
|
||||||
"is_exchange_gain_loss",
|
"is_exchange_gain_loss",
|
||||||
"description"
|
"description",
|
||||||
|
"dunning"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -55,12 +56,21 @@
|
|||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Is Exchange Gain / Loss?",
|
"label": "Is Exchange Gain / Loss?",
|
||||||
"read_only": 1
|
"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,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-11 14:26:11.312950",
|
"modified": "2026-08-17 11:20:35.482913",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Payment Entry Deduction",
|
"name": "Payment Entry Deduction",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
|
|||||||
amount: DF.Currency
|
amount: DF.Currency
|
||||||
cost_center: DF.Link
|
cost_center: DF.Link
|
||||||
description: DF.SmallText | None
|
description: DF.SmallText | None
|
||||||
|
dunning: DF.Link | None
|
||||||
is_exchange_gain_loss: DF.Check
|
is_exchange_gain_loss: DF.Check
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
|||||||
const me = this;
|
const me = this;
|
||||||
super.refresh();
|
super.refresh();
|
||||||
|
|
||||||
hide_fields(this.frm.doc);
|
hide_fields(this.frm);
|
||||||
// Show / Hide button
|
// Show / Hide button
|
||||||
this.show_general_ledger();
|
this.show_general_ledger();
|
||||||
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
|
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
|
||||||
@@ -435,7 +435,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
|||||||
}
|
}
|
||||||
|
|
||||||
is_paid() {
|
is_paid() {
|
||||||
hide_fields(this.frm.doc);
|
hide_fields(this.frm);
|
||||||
if (cint(this.frm.doc.is_paid)) {
|
if (cint(this.frm.doc.is_paid)) {
|
||||||
this.frm.set_value("allocate_advances_automatically", 0);
|
this.frm.set_value("allocate_advances_automatically", 0);
|
||||||
this.frm.set_value("payment_terms_template", "");
|
this.frm.set_value("payment_terms_template", "");
|
||||||
@@ -499,28 +499,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
|
|||||||
|
|
||||||
// Hide Fields
|
// Hide Fields
|
||||||
// ------------
|
// ------------
|
||||||
function hide_fields(doc) {
|
function hide_fields(frm) {
|
||||||
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
|
const doc = frm.doc;
|
||||||
|
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
|
||||||
|
|
||||||
if (cint(doc.is_paid) == 1) {
|
if (cint(doc.is_paid) == 1) {
|
||||||
hide_field(parent_fields);
|
frm.toggle_display(parent_fields, false);
|
||||||
} else {
|
} else {
|
||||||
for (var i in parent_fields) {
|
for (const fieldname of parent_fields) {
|
||||||
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
|
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
|
||||||
if (!docfield.hidden) unhide_field(parent_fields[i]);
|
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"]) {
|
if (frm.fields_dict["items"]) {
|
||||||
cur_frm.fields_dict["items"].grid.set_column_disp(
|
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
|
||||||
item_fields_stock,
|
|
||||||
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cur_frm.refresh_fields();
|
frm.refresh_fields();
|
||||||
}
|
}
|
||||||
|
|
||||||
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
|
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
|
||||||
@@ -736,7 +734,7 @@ frappe.ui.form.on("Purchase Invoice", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
update_stock: function (frm) {
|
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);
|
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3081,6 +3081,23 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
|||||||
|
|
||||||
self.assertRaises(StockOverReturnError, return_doc.save)
|
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):
|
def test_apply_discount_on_grand_total(self):
|
||||||
"""
|
"""
|
||||||
To test if after applying discount on grand total,
|
To test if after applying discount on grand total,
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ class RepostPaymentLedger(Document):
|
|||||||
def execute_repost_payment_ledger(docname):
|
def execute_repost_payment_ledger(docname):
|
||||||
"""Repost Payment Ledger Entries by background job."""
|
"""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
|
job_name = "payment_ledger_repost_" + docname
|
||||||
|
|
||||||
frappe.enqueue(
|
frappe.enqueue(
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ class AccountsController(TransactionBase):
|
|||||||
if self.get("_action") and self._action != "update_after_submit":
|
if self.get("_action") and self._action != "update_after_submit":
|
||||||
self.set_missing_values(for_validate=True)
|
self.set_missing_values(for_validate=True)
|
||||||
|
|
||||||
|
self.validate_price_list()
|
||||||
|
|
||||||
if self.get("_action") == "submit":
|
if self.get("_action") == "submit":
|
||||||
self.remove_bundle_for_non_stock_invoices()
|
self.remove_bundle_for_non_stock_invoices()
|
||||||
|
|
||||||
@@ -384,6 +386,28 @@ class AccountsController(TransactionBase):
|
|||||||
def is_drop_ship(items):
|
def is_drop_ship(items):
|
||||||
return any(item.delivered_by_supplier for item in items)
|
return any(item.delivered_by_supplier for item in items)
|
||||||
|
|
||||||
|
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):
|
def set_default_letter_head(self):
|
||||||
if hasattr(self, "letter_head") and not self.letter_head:
|
if hasattr(self, "letter_head") and not self.letter_head:
|
||||||
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
||||||
|
|||||||
@@ -190,7 +190,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:
|
if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock:
|
||||||
fields = ["qty"]
|
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"):
|
if not args.get("return_qty_from_rejected_warehouse"):
|
||||||
fields.extend(["received_qty", "rejected_qty"])
|
fields.extend(["received_qty", "rejected_qty"])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1533,7 +1533,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||||
filters = frappe._dict(company=company)
|
filters = frappe._dict(company=company, valuation_field_type="Currency")
|
||||||
doc = frappe.get_doc(doctype, docname)
|
doc = frappe.get_doc(doctype, docname)
|
||||||
doc.check_permission("read")
|
doc.check_permission("read")
|
||||||
doc.run_method("before_sl_preview")
|
doc.run_method("before_sl_preview")
|
||||||
@@ -1574,7 +1574,7 @@ def get_accounting_ledger_preview(doc, filters):
|
|||||||
columns = get_gl_columns(filters)
|
columns = get_gl_columns(filters)
|
||||||
gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields)
|
gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||||
|
|
||||||
gl_columns = get_columns(columns, fields)
|
gl_columns = get_columns(columns, fields, erpnext.get_company_currency(filters.company))
|
||||||
gl_data = get_data(fields, gl_entries)
|
gl_data = get_data(fields, gl_entries)
|
||||||
|
|
||||||
return gl_columns, gl_data
|
return gl_columns, gl_data
|
||||||
@@ -1616,7 +1616,7 @@ def get_stock_ledger_preview(doc, filters):
|
|||||||
columns = get_sl_columns(filters)
|
columns = get_sl_columns(filters)
|
||||||
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
|
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||||
|
|
||||||
sl_columns = get_columns(columns, columns_fields)
|
sl_columns = get_columns(columns, columns_fields, erpnext.get_company_currency(filters.company))
|
||||||
sl_data = get_data(columns_fields, sl_entries)
|
sl_data = get_data(columns_fields, sl_entries)
|
||||||
|
|
||||||
return sl_columns, sl_data
|
return sl_columns, sl_data
|
||||||
@@ -1635,7 +1635,8 @@ def get_sl_entries_for_preview(doctype, docname, fields):
|
|||||||
entry["out_qty"] = abs(entry.actual_qty)
|
entry["out_qty"] = abs(entry.actual_qty)
|
||||||
entry["in_qty"] = 0
|
entry["in_qty"] = 0
|
||||||
|
|
||||||
entry["in_out_rate"] = entry["valuation_rate"]
|
if entry.actual_qty < 0:
|
||||||
|
entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty
|
||||||
|
|
||||||
return sl_entries
|
return sl_entries
|
||||||
|
|
||||||
@@ -1644,12 +1645,23 @@ def get_gl_entries_for_preview(doctype, docname, fields):
|
|||||||
return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields)
|
return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields)
|
||||||
|
|
||||||
|
|
||||||
def get_columns(raw_columns, fields):
|
def get_columns(raw_columns, fields, currency):
|
||||||
return [
|
columns = []
|
||||||
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
|
for source_column in raw_columns:
|
||||||
for d in raw_columns
|
if source_column.get("hidden") or source_column.get("fieldname") not in fields:
|
||||||
if not d.get("hidden") and d.get("fieldname") in fields
|
continue
|
||||||
]
|
|
||||||
|
column = {
|
||||||
|
"name": source_column.get("label"),
|
||||||
|
"editable": False,
|
||||||
|
"width": 110,
|
||||||
|
"fieldtype": source_column.get("fieldtype"),
|
||||||
|
}
|
||||||
|
if column["fieldtype"] == "Currency":
|
||||||
|
column["options"] = currency
|
||||||
|
columns.append(column)
|
||||||
|
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
def get_data(raw_columns, raw_data):
|
def get_data(raw_columns, raw_data):
|
||||||
|
|||||||
23
erpnext/controllers/tests/test_ledger_preview.py
Normal file
23
erpnext/controllers/tests/test_ledger_preview.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
from erpnext.controllers.stock_controller import get_sl_entries_for_preview
|
||||||
|
|
||||||
|
|
||||||
|
class TestLedgerPreview(TestCase):
|
||||||
|
def test_in_out_rate_is_only_set_for_outgoing_entries(self):
|
||||||
|
stock_ledger_entries = [
|
||||||
|
frappe._dict(actual_qty=5, stock_value_difference=10),
|
||||||
|
frappe._dict(actual_qty=-5, stock_value_difference=-15),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("frappe.get_all", return_value=stock_ledger_entries):
|
||||||
|
entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", [])
|
||||||
|
|
||||||
|
self.assertIsNone(entries[0].get("in_out_rate"))
|
||||||
|
self.assertEqual(entries[1].in_out_rate, 3)
|
||||||
@@ -53,7 +53,7 @@ def set_booking_setting(field, value):
|
|||||||
|
|
||||||
|
|
||||||
def slot_on(days_from_now, hour, minute=0):
|
def slot_on(days_from_now, hour, minute=0):
|
||||||
day = datetime.date.today() + datetime.timedelta(days=days_from_now)
|
day = getdate() + datetime.timedelta(days=days_from_now)
|
||||||
return datetime.datetime.combine(day, datetime.time(hour, minute))
|
return datetime.datetime.combine(day, datetime.time(hour, minute))
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ class TestAppointment(FrappeTestCase):
|
|||||||
|
|
||||||
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
|
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
|
||||||
appointment = create_appointment(
|
appointment = create_appointment(
|
||||||
date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)),
|
date=str(getdate() + datetime.timedelta(days=days_from_now)),
|
||||||
time=time,
|
time=time,
|
||||||
tz=get_system_timezone(),
|
tz=get_system_timezone(),
|
||||||
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
|
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
|
||||||
@@ -280,7 +280,7 @@ class TestAppointment(FrappeTestCase):
|
|||||||
|
|
||||||
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
|
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
|
||||||
create_appointment(
|
create_appointment(
|
||||||
date=str(datetime.date.today() + datetime.timedelta(days=3)),
|
date=str(getdate() + datetime.timedelta(days=3)),
|
||||||
time="10:00:00",
|
time="10:00:00",
|
||||||
tz="UTC",
|
tz="UTC",
|
||||||
contact={
|
contact={
|
||||||
@@ -295,7 +295,7 @@ class TestAppointment(FrappeTestCase):
|
|||||||
def test_booked_slot_unavailable_on_portal(self):
|
def test_booked_slot_unavailable_on_portal(self):
|
||||||
self._configure_booking_settings()
|
self._configure_booking_settings()
|
||||||
tz = get_system_timezone()
|
tz = get_system_timezone()
|
||||||
day = datetime.date.today() + datetime.timedelta(days=2)
|
day = getdate() + datetime.timedelta(days=2)
|
||||||
|
|
||||||
def get_availability():
|
def get_availability():
|
||||||
with self.set_user("Guest"):
|
with self.set_user("Guest"):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import frappe
|
|||||||
from frappe import _, bold
|
from frappe import _, bold
|
||||||
from frappe.core.doctype.version.version import get_diff
|
from frappe.core.doctype.version.version import get_diff
|
||||||
from frappe.model.mapper import get_mapped_doc
|
from frappe.model.mapper import get_mapped_doc
|
||||||
from frappe.utils import cint, cstr, flt, today
|
from frappe.utils import cint, cstr, flt, get_link_to_form, today
|
||||||
from frappe.website.website_generator import WebsiteGenerator
|
from frappe.website.website_generator import WebsiteGenerator
|
||||||
|
|
||||||
import erpnext
|
import erpnext
|
||||||
@@ -653,6 +653,19 @@ class BOM(WebsiteGenerator):
|
|||||||
frappe.throw(_("Quantity required for Item {0} in row {1}").format(m.item_code, m.idx))
|
frappe.throw(_("Quantity required for Item {0} in row {1}").format(m.item_code, m.idx))
|
||||||
check_list.append(m)
|
check_list.append(m)
|
||||||
|
|
||||||
|
bom_items = {self.item}
|
||||||
|
bom_items.update(d.item_code for d in self.get("items"))
|
||||||
|
bom_items.update(d.item_code for d in self.get("scrap_items"))
|
||||||
|
|
||||||
|
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, bom_list=None):
|
def check_recursion(self, bom_list=None):
|
||||||
"""Check whether recursion occurs in any bom"""
|
"""Check whether recursion occurs in any bom"""
|
||||||
|
|
||||||
|
|||||||
@@ -1182,7 +1182,7 @@ def make_material_request(source_name, target_doc=None):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def make_stock_entry(source_name, target_doc=None):
|
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
|
||||||
def update_item(source, target, source_parent):
|
def update_item(source, target, source_parent):
|
||||||
target.t_warehouse = source_parent.wip_warehouse
|
target.t_warehouse = source_parent.wip_warehouse
|
||||||
|
|
||||||
@@ -1194,6 +1194,9 @@ def make_stock_entry(source_name, target_doc=None):
|
|||||||
target.qty = pending_rm_qty
|
target.qty = pending_rm_qty
|
||||||
|
|
||||||
def set_missing_values(source, target):
|
def set_missing_values(source, target):
|
||||||
|
if not source.items:
|
||||||
|
frappe.throw(_("This Job Card has no raw materials to transfer."))
|
||||||
|
|
||||||
target.purpose = "Material Transfer for Manufacture"
|
target.purpose = "Material Transfer for Manufacture"
|
||||||
target.from_bom = 1
|
target.from_bom = 1
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,66 @@ import frappe
|
|||||||
from frappe.test_runner import make_test_records
|
from frappe.test_runner import make_test_records
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry
|
||||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
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.routing.test_routing import create_routing, setup_bom
|
||||||
from erpnext.manufacturing.doctype.workstation.workstation import (
|
from erpnext.manufacturing.doctype.workstation.workstation import (
|
||||||
NotInWorkingHoursError,
|
NotInWorkingHoursError,
|
||||||
WorkstationHolidayError,
|
WorkstationHolidayError,
|
||||||
check_if_within_operating_hours,
|
check_if_within_operating_hours,
|
||||||
|
get_raw_materials,
|
||||||
)
|
)
|
||||||
|
|
||||||
test_dependencies = ["Warehouse"]
|
test_dependencies = ["Warehouse", "Item"]
|
||||||
test_records = frappe.get_test_records("Workstation")
|
test_records = frappe.get_test_records("Workstation")
|
||||||
make_test_records("Workstation")
|
make_test_records("Workstation")
|
||||||
|
|
||||||
|
|
||||||
class TestWorkstation(FrappeTestCase):
|
class TestWorkstation(FrappeTestCase):
|
||||||
|
def test_get_raw_materials_without_items(self):
|
||||||
|
job_card = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Job Card",
|
||||||
|
"company": "_Test Company",
|
||||||
|
"wip_warehouse": "_Test Warehouse 1 - _TC",
|
||||||
|
}
|
||||||
|
).insert(ignore_mandatory=True)
|
||||||
|
|
||||||
|
self.assertEqual(get_raw_materials([job_card.name]), {})
|
||||||
|
with self.assertRaisesRegex(frappe.ValidationError, "This Job Card has no raw materials to transfer"):
|
||||||
|
make_stock_entry(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_with_items(self):
|
||||||
|
job_card = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Job Card",
|
||||||
|
"company": "_Test Company",
|
||||||
|
"wip_warehouse": "_Test Warehouse 1 - _TC",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_code": "_Test Item",
|
||||||
|
"source_warehouse": "_Test Warehouse - _TC",
|
||||||
|
"required_qty": 5,
|
||||||
|
"transferred_qty": 2,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
).insert(ignore_mandatory=True)
|
||||||
|
|
||||||
|
materials = get_raw_materials([job_card.name])
|
||||||
|
|
||||||
|
self.assertEqual(list(materials), [job_card.name])
|
||||||
|
self.assertEqual(len(materials[job_card.name]), 1)
|
||||||
|
material = materials[job_card.name][0]
|
||||||
|
self.assertEqual(material.item_code, "_Test Item")
|
||||||
|
self.assertEqual(material.required_qty, 5)
|
||||||
|
self.assertEqual(material.transferred_qty, 2)
|
||||||
|
self.assertEqual(material.source_warehouse, "_Test Warehouse - _TC")
|
||||||
|
|
||||||
def test_validate_timings(self):
|
def test_validate_timings(self):
|
||||||
check_if_within_operating_hours(
|
check_if_within_operating_hours(
|
||||||
"_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00"
|
"_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00"
|
||||||
|
|||||||
@@ -450,3 +450,5 @@ erpnext.patches.v16_0.merge_seeded_item_group_root
|
|||||||
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
|
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
|
||||||
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
|
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
|
||||||
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
|
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
|
||||||
|
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
|
||||||
|
erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates
|
||||||
|
|||||||
@@ -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,226 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
# Snapshot of the relevant German defaults when this migration was written.
|
||||||
|
# Migration patches must not read mutable setup data, otherwise future edits to
|
||||||
|
# country_wise_tax.json would change what this patch does on sites that have not
|
||||||
|
# run it yet.
|
||||||
|
#
|
||||||
|
# For numbered charts, compare account_number + root_type because Account.account_name
|
||||||
|
# is not unique within a company.
|
||||||
|
SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset(
|
||||||
|
{
|
||||||
|
("3801", "Liability"),
|
||||||
|
("3802", "Liability"),
|
||||||
|
("3835", "Liability"),
|
||||||
|
("1401", "Asset"),
|
||||||
|
("1402", "Asset"),
|
||||||
|
("1541", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset(
|
||||||
|
{
|
||||||
|
("3806", "Liability"),
|
||||||
|
("3804", "Liability"),
|
||||||
|
("3837", "Liability"),
|
||||||
|
("1406", "Asset"),
|
||||||
|
("1404", "Asset"),
|
||||||
|
("1540", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset(
|
||||||
|
{
|
||||||
|
("1771", "Liability"),
|
||||||
|
("1772", "Liability"),
|
||||||
|
("1785", "Liability"),
|
||||||
|
("1571", "Asset"),
|
||||||
|
("1572", "Asset"),
|
||||||
|
("1541", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset(
|
||||||
|
{
|
||||||
|
("1776", "Liability"),
|
||||||
|
("1774", "Liability"),
|
||||||
|
("1787", "Liability"),
|
||||||
|
("1576", "Asset"),
|
||||||
|
("1574", "Asset"),
|
||||||
|
("1540", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS = frozenset(
|
||||||
|
{
|
||||||
|
("Umsatzsteuer 7 %", "Liability"),
|
||||||
|
("Umsatzsteuer aus innergemeinschaftlichem Erwerb", "Liability"),
|
||||||
|
("Umsatzsteuer nach § 13b UStG", "Liability"),
|
||||||
|
("Abziehbare Vorsteuer 7 %", "Asset"),
|
||||||
|
("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb", "Asset"),
|
||||||
|
("Abziehbare Vorsteuer nach § 13b UStG", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS = frozenset(
|
||||||
|
{
|
||||||
|
("Umsatzsteuer 19 %", "Liability"),
|
||||||
|
("Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %", "Liability"),
|
||||||
|
("Umsatzsteuer nach § 13b UStG 19 %", "Liability"),
|
||||||
|
("Abziehbare Vorsteuer 19 %", "Asset"),
|
||||||
|
("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %", "Asset"),
|
||||||
|
("Abziehbare Vorsteuer nach § 13b UStG 19 %", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset(
|
||||||
|
{
|
||||||
|
("2321", "Liability"),
|
||||||
|
("2331", "Liability"),
|
||||||
|
("2341", "Liability"),
|
||||||
|
("1521", "Asset"),
|
||||||
|
("1531", "Asset"),
|
||||||
|
("1541", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset(
|
||||||
|
{
|
||||||
|
("2320", "Liability"),
|
||||||
|
("2330", "Liability"),
|
||||||
|
("2340", "Liability"),
|
||||||
|
("1520", "Asset"),
|
||||||
|
("1530", "Asset"),
|
||||||
|
("1540", "Asset"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS = {
|
||||||
|
"SKR03 mit Kontonummern": {
|
||||||
|
"identifier_field": "account_number",
|
||||||
|
"templates": {
|
||||||
|
"19 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS,
|
||||||
|
"7 %": SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS,
|
||||||
|
"0 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS
|
||||||
|
| SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS
|
||||||
|
| frozenset({("1588", "Asset")}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"SKR04 mit Kontonummern": {
|
||||||
|
"identifier_field": "account_number",
|
||||||
|
"templates": {
|
||||||
|
"19 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS,
|
||||||
|
"7 %": SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS,
|
||||||
|
"0 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS
|
||||||
|
| SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS
|
||||||
|
| frozenset({("1433", "Asset")}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Standard": {
|
||||||
|
"identifier_field": "account_name",
|
||||||
|
"templates": {
|
||||||
|
"19 %": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS,
|
||||||
|
"7 %": STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS,
|
||||||
|
"0%": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS
|
||||||
|
| STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS
|
||||||
|
| frozenset({("Entstandene Einfuhrumsatzsteuer", "Asset")}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Standard with Numbers": {
|
||||||
|
"identifier_field": "account_number",
|
||||||
|
"templates": {
|
||||||
|
"19%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS,
|
||||||
|
"7%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS,
|
||||||
|
"0 %": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS
|
||||||
|
| STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS
|
||||||
|
| frozenset({("1550", "Asset")}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_cache(accounts, account_cache):
|
||||||
|
missing_accounts = set(accounts) - set(account_cache)
|
||||||
|
if not missing_accounts:
|
||||||
|
return
|
||||||
|
|
||||||
|
for account in frappe.get_all(
|
||||||
|
"Account",
|
||||||
|
filters={"name": ("in", tuple(sorted(missing_accounts)))},
|
||||||
|
fields=["name", "account_name", "account_number", "root_type"],
|
||||||
|
):
|
||||||
|
account_cache[account.name] = account
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_identifier(account, identifier_field, account_cache):
|
||||||
|
cached_account = account_cache.get(account)
|
||||||
|
if not cached_account:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cached_account.get(identifier_field), cached_account.root_type
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
"""Backfill `not_applicable` on Item Tax Template Details for German companies.
|
||||||
|
|
||||||
|
Before the `not_applicable` flag existed, German default templates used
|
||||||
|
`tax_rate: 0` to mean "this tax does not apply to the item" (as opposed to
|
||||||
|
an explicit 0% rate). For each German company, this patch looks up the
|
||||||
|
historical defaults for its Chart of Accounts and sets
|
||||||
|
`not_applicable = 1` on detail rows that still match those defaults
|
||||||
|
(same template title, same zero-rate tax account identifier set, flag still unset),
|
||||||
|
leaving any user-customised rows untouched.
|
||||||
|
"""
|
||||||
|
companies = frappe.get_all(
|
||||||
|
"Company",
|
||||||
|
filters={"country": "Germany"},
|
||||||
|
fields=["name", "chart_of_accounts"],
|
||||||
|
)
|
||||||
|
account_cache = {}
|
||||||
|
|
||||||
|
for company in companies:
|
||||||
|
chart = GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS.get(company.chart_of_accounts)
|
||||||
|
if not chart:
|
||||||
|
continue
|
||||||
|
|
||||||
|
identifier_field = chart["identifier_field"]
|
||||||
|
for template_title, target_accounts in chart["templates"].items():
|
||||||
|
itt_names = frappe.get_all(
|
||||||
|
"Item Tax Template",
|
||||||
|
filters={"company": company.name, "title": template_title},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
for itt_name in itt_names:
|
||||||
|
zero_rate_details = frappe.get_all(
|
||||||
|
"Item Tax Template Detail",
|
||||||
|
filters={"parent": itt_name, "tax_rate": 0},
|
||||||
|
fields=["name", "tax_type", "not_applicable"],
|
||||||
|
)
|
||||||
|
update_account_cache((d.tax_type for d in zero_rate_details), account_cache)
|
||||||
|
zero_rate_accounts_by_detail = {
|
||||||
|
d.name: get_account_identifier(d.tax_type, identifier_field, account_cache)
|
||||||
|
for d in zero_rate_details
|
||||||
|
}
|
||||||
|
if any(identifier is None for identifier in zero_rate_accounts_by_detail.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Compare the row count as well. Account names are only implicitly unique
|
||||||
|
# among number-less accounts (`Account.name` is `[number - ]account_name - abbr`),
|
||||||
|
# so on a mixed chart a numbered account can share `account_name` with a
|
||||||
|
# default one. Without this, such a user-added zero-rate row collapses onto a
|
||||||
|
# default identifier and makes a customised template look untouched.
|
||||||
|
if len(zero_rate_accounts_by_detail) != len(target_accounts):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if set(zero_rate_accounts_by_detail.values()) != target_accounts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for d in zero_rate_details:
|
||||||
|
if not d.not_applicable:
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Item Tax Template Detail",
|
||||||
|
d.name,
|
||||||
|
"not_applicable",
|
||||||
|
1,
|
||||||
|
update_modified=False,
|
||||||
|
)
|
||||||
@@ -83,7 +83,7 @@ erpnext.accounts.ledger_preview = {
|
|||||||
columns.forEach((col) => {
|
columns.forEach((col) => {
|
||||||
if (col.fieldtype === "Currency") {
|
if (col.fieldtype === "Currency") {
|
||||||
col.format = (value) => {
|
col.format = (value) => {
|
||||||
return format_currency(value);
|
return format_currency(value, col.options);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
|
|||||||
args = {
|
args = {
|
||||||
party: frm.doc.customer || frm.doc.party_name,
|
party: frm.doc.customer || frm.doc.party_name,
|
||||||
party_type: party_type,
|
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) {
|
} else if (frm.doc.supplier) {
|
||||||
args = {
|
args = {
|
||||||
party: frm.doc.supplier,
|
party: frm.doc.supplier,
|
||||||
party_type: "Supplier",
|
party_type: "Supplier",
|
||||||
bill_date: frm.doc.bill_date,
|
bill_date: frm.doc.bill_date,
|
||||||
price_list: frm.doc.buying_price_list,
|
price_list: frappe.defaults.get_default("buying_price_list"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,20 +31,4 @@ frappe.query_reports["IRS 1099"] = {
|
|||||||
width: 80,
|
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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,46 +84,6 @@ def get_columns():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def irs_1099_print(filters):
|
|
||||||
if not filters:
|
|
||||||
frappe._dict(
|
|
||||||
{
|
|
||||||
"company": frappe.db.get_default("Company"),
|
|
||||||
"fiscal_year": frappe.db.get_default("Fiscal Year"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
filters = frappe._dict(json.loads(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):
|
def get_payer_address_html(company):
|
||||||
address_list = frappe.db.sql(
|
address_list = frappe.db.sql(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -609,11 +609,8 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
|
|||||||
|
|
||||||
# if the current user does not have permissions to override credit limit,
|
# if the current user does not have permissions to override credit limit,
|
||||||
# prompt them to send out an email to the controller users
|
# prompt them to send out an email to the controller users
|
||||||
frappe.msgprint(
|
primary_action = (
|
||||||
message,
|
{
|
||||||
title=_("Credit Limit Crossed"),
|
|
||||||
raise_exception=1,
|
|
||||||
primary_action={
|
|
||||||
"label": "Send Email",
|
"label": "Send Email",
|
||||||
"server_action": "erpnext.selling.doctype.customer.customer.send_emails",
|
"server_action": "erpnext.selling.doctype.customer.customer.send_emails",
|
||||||
"hide_on_success": True,
|
"hide_on_success": True,
|
||||||
@@ -623,7 +620,16 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
|
|||||||
"credit_limit": credit_limit,
|
"credit_limit": credit_limit,
|
||||||
"credit_controller_users_list": credit_controller_users,
|
"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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -631,6 +637,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
|
|||||||
def send_emails(args):
|
def send_emails(args):
|
||||||
args = json.loads(args)
|
args = json.loads(args)
|
||||||
subject = _("Credit limit reached for customer {0}").format(args.get("customer"))
|
subject = _("Credit limit reached for customer {0}").format(args.get("customer"))
|
||||||
|
frappe.has_permission("Customer", ptype="email", doc=args.get("customer"), throw=True)
|
||||||
message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format(
|
message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format(
|
||||||
args.get("customer"), args.get("customer_outstanding"), args.get("credit_limit")
|
args.get("customer"), args.get("customer_outstanding"), args.get("credit_limit")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from frappe.model.document import Document
|
|||||||
from frappe.model.mapper import get_mapped_doc
|
from frappe.model.mapper import get_mapped_doc
|
||||||
from frappe.model.utils import get_fetch_values
|
from frappe.model.utils import get_fetch_values
|
||||||
from frappe.query_builder import Case, Criterion
|
from frappe.query_builder import Case, Criterion
|
||||||
from frappe.query_builder.functions import Abs, Sum
|
from frappe.query_builder.functions import Abs, IfNull, Round, Sum
|
||||||
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html
|
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html
|
||||||
from pypika import Order
|
from pypika import Order
|
||||||
|
|
||||||
@@ -1973,8 +1973,26 @@ def get_stock_reservation_status():
|
|||||||
return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation")
|
return frappe.db.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):
|
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"))
|
global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance"))
|
||||||
allowance = (
|
allowance = (
|
||||||
Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
|
Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
|
||||||
@@ -1984,8 +2002,11 @@ 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)
|
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)
|
is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0)
|
||||||
|
is_billable_row = (
|
||||||
|
(sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item)
|
||||||
|
)
|
||||||
|
|
||||||
return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
|
return is_unit_price_row | is_billable_row
|
||||||
|
|
||||||
|
|
||||||
def has_potentially_billable_items(sales_order: str) -> bool:
|
def has_potentially_billable_items(sales_order: str) -> bool:
|
||||||
|
|||||||
@@ -290,6 +290,48 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(make_sales_invoice(so.name).items), 0)
|
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):
|
def test_make_sales_invoice_after_return_and_redelivery(self):
|
||||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,8 @@ def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True
|
|||||||
account = warehouse.account
|
account = warehouse.account
|
||||||
if not account and warehouse.parent_warehouse:
|
if not account and warehouse.parent_warehouse:
|
||||||
if warehouse_account:
|
if warehouse_account:
|
||||||
if warehouse_account.get(warehouse.parent_warehouse):
|
if parent := warehouse_account.get(warehouse.parent_warehouse):
|
||||||
account = warehouse_account.get(warehouse.parent_warehouse).account
|
account = parent.account
|
||||||
else:
|
|
||||||
from frappe.utils.nestedset import rebuild_tree
|
|
||||||
|
|
||||||
rebuild_tree("Warehouse", "parent_warehouse")
|
|
||||||
else:
|
else:
|
||||||
account = frappe.db.sql(
|
account = frappe.db.sql(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1099,6 +1099,67 @@ class TestDeliveryNote(FrappeTestCase):
|
|||||||
self.assertEqual(dn.per_billed, 50)
|
self.assertEqual(dn.per_billed, 50)
|
||||||
self.assertEqual(dn.status, "Partially Billed")
|
self.assertEqual(dn.status, "Partially Billed")
|
||||||
|
|
||||||
|
def test_billing_status_repair_patch(self):
|
||||||
|
"""Returns submitted before #58869 left the original Delivery Note's per_billed stale.
|
||||||
|
|
||||||
|
The repair patch recalculates such notes: a directly invoiced one whose remaining
|
||||||
|
qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back
|
||||||
|
to To Bill.
|
||||||
|
"""
|
||||||
|
from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch
|
||||||
|
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||||
|
|
||||||
|
# Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed
|
||||||
|
make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100)
|
||||||
|
dn = create_delivery_note(qty=5)
|
||||||
|
|
||||||
|
si = make_sales_invoice(dn.name)
|
||||||
|
si.items[0].qty = 2
|
||||||
|
si.insert()
|
||||||
|
si.submit()
|
||||||
|
|
||||||
|
dn_return = make_sales_return(dn.name)
|
||||||
|
dn_return.items[0].qty = -3
|
||||||
|
dn_return.insert()
|
||||||
|
# Mimic the submit request, which reconstructs the document from client data.
|
||||||
|
frappe.get_doc(dn_return.as_dict()).submit()
|
||||||
|
|
||||||
|
dn.load_from_db()
|
||||||
|
self.assertEqual(dn.items[0].returned_qty, 3)
|
||||||
|
self.assertEqual(dn.per_billed, 100)
|
||||||
|
|
||||||
|
# Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled
|
||||||
|
so = make_sales_order(qty=10)
|
||||||
|
so_dn = create_dn_against_so(so.name, delivered_qty=5)
|
||||||
|
|
||||||
|
so_dn_return = make_sales_return(so_dn.name)
|
||||||
|
so_dn_return.items[0].qty = -2
|
||||||
|
so_dn_return.insert()
|
||||||
|
frappe.get_doc(so_dn_return.as_dict()).submit()
|
||||||
|
|
||||||
|
so_dn.load_from_db()
|
||||||
|
self.assertEqual(so_dn.items[0].returned_qty, 2)
|
||||||
|
self.assertEqual(so_dn.per_billed, 0)
|
||||||
|
|
||||||
|
# Mimic the state left behind by a return submitted before the fix
|
||||||
|
for name, per_billed in ((dn.name, 40), (so_dn.name, 50)):
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Delivery Note",
|
||||||
|
name,
|
||||||
|
{"per_billed": per_billed, "status": "Partially Billed"},
|
||||||
|
update_modified=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
patch.execute()
|
||||||
|
|
||||||
|
dn.load_from_db()
|
||||||
|
self.assertEqual(dn.per_billed, 100)
|
||||||
|
self.assertEqual(dn.status, "Completed")
|
||||||
|
|
||||||
|
so_dn.load_from_db()
|
||||||
|
self.assertEqual(so_dn.per_billed, 0)
|
||||||
|
self.assertEqual(so_dn.status, "To Bill")
|
||||||
|
|
||||||
def test_dn_billing_status_case2(self):
|
def test_dn_billing_status_case2(self):
|
||||||
# SO -> SI and SO -> DN1, DN2
|
# SO -> SI and SO -> DN1, DN2
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class DeliveryTrip(Document):
|
|||||||
"UOM Conversion Factor", {"from_uom": "Meter", "to_uom": self.default_distance_uom}, "value"
|
"UOM Conversion Factor", {"from_uom": "Meter", "to_uom": self.default_distance_uom}, "value"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def after_mapping(self, source_doc):
|
||||||
|
for stop in self.delivery_stops[:]:
|
||||||
|
if not any(stop.get(df.fieldname) for df in stop.meta.fields):
|
||||||
|
self.remove(stop)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
if self._action == "submit" and not self.driver:
|
if self._action == "submit" and not self.driver:
|
||||||
frappe.throw(_("A driver must be set to submit."))
|
frappe.throw(_("A driver must be set to submit."))
|
||||||
@@ -69,7 +74,7 @@ class DeliveryTrip(Document):
|
|||||||
|
|
||||||
def validate_stop_addresses(self):
|
def validate_stop_addresses(self):
|
||||||
for stop in self.delivery_stops:
|
for stop in self.delivery_stops:
|
||||||
if not stop.customer_address:
|
if stop.address and not stop.customer_address:
|
||||||
stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict())
|
stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict())
|
||||||
|
|
||||||
def update_status(self):
|
def update_status(self):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from frappe.tests.utils import FrappeTestCase
|
|||||||
from frappe.utils import add_days, flt, now_datetime, nowdate
|
from frappe.utils import add_days, flt, now_datetime, nowdate
|
||||||
|
|
||||||
import erpnext
|
import erpnext
|
||||||
|
from erpnext.stock.doctype.delivery_note.delivery_note import make_delivery_trip
|
||||||
from erpnext.stock.doctype.delivery_trip.delivery_trip import (
|
from erpnext.stock.doctype.delivery_trip.delivery_trip import (
|
||||||
get_contact_and_address,
|
get_contact_and_address,
|
||||||
notify_customers,
|
notify_customers,
|
||||||
@@ -100,6 +101,32 @@ class TestDeliveryTrip(FrappeTestCase):
|
|||||||
self.delivery_trip.save()
|
self.delivery_trip.save()
|
||||||
self.assertEqual(self.delivery_trip.status, "Completed")
|
self.assertEqual(self.delivery_trip.status, "Completed")
|
||||||
|
|
||||||
|
def map_delivery_note_onto_trip(self, existing_stop):
|
||||||
|
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||||
|
|
||||||
|
delivery_note = create_delivery_note()
|
||||||
|
trip = frappe.new_doc("Delivery Trip")
|
||||||
|
trip.append("delivery_stops", existing_stop)
|
||||||
|
|
||||||
|
return delivery_note, make_delivery_trip(delivery_note.name, trip)
|
||||||
|
|
||||||
|
def test_mapping_drops_placeholder_stop(self):
|
||||||
|
delivery_note, trip = self.map_delivery_note_onto_trip({})
|
||||||
|
|
||||||
|
self.assertEqual(len(trip.delivery_stops), 1)
|
||||||
|
self.assertEqual(trip.delivery_stops[0].delivery_note, delivery_note.name)
|
||||||
|
|
||||||
|
def test_mapping_keeps_partially_filled_stop(self):
|
||||||
|
_, trip = self.map_delivery_note_onto_trip({"customer": "_Test Customer"})
|
||||||
|
|
||||||
|
self.assertEqual(len(trip.delivery_stops), 2)
|
||||||
|
self.assertIsNone(trip.delivery_stops[0].delivery_note)
|
||||||
|
|
||||||
|
def test_stop_without_address_throws_mandatory_error(self):
|
||||||
|
self.delivery_trip.append("delivery_stops", {"customer": "_Test Customer"})
|
||||||
|
|
||||||
|
self.assertRaises(frappe.MandatoryError, self.delivery_trip.save)
|
||||||
|
|
||||||
|
|
||||||
def create_address(driver):
|
def create_address(driver):
|
||||||
if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}):
|
if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}):
|
||||||
|
|||||||
@@ -73,24 +73,44 @@ frappe.ui.form.on("Inventory Dimension", {
|
|||||||
frm.trigger("set_parent_fields");
|
frm.trigger("set_parent_fields");
|
||||||
},
|
},
|
||||||
|
|
||||||
set_parent_fields(frm) {
|
istable(frm) {
|
||||||
if (frm.doc.apply_to_all_doctypes) {
|
frm.trigger("set_parent_fields");
|
||||||
let options = ["\n", frm.doc.reference_document];
|
},
|
||||||
|
|
||||||
frm.set_df_property("fetch_from_parent", "options", options);
|
reference_document(frm) {
|
||||||
} else if (frm.doc.document_type && frm.doc.istable) {
|
frm.trigger("set_parent_fields");
|
||||||
|
},
|
||||||
|
|
||||||
|
apply_to_all_doctypes(frm) {
|
||||||
|
frm.trigger("set_parent_fields");
|
||||||
|
},
|
||||||
|
|
||||||
|
set_parent_fields(frm) {
|
||||||
|
const { reference_document, document_type } = frm.doc;
|
||||||
|
if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) {
|
||||||
|
return set_parent_field_options(frm, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frm.doc.apply_to_all_doctypes) {
|
||||||
|
return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]);
|
||||||
|
} else if (document_type && frm.doc.istable) {
|
||||||
frappe.call({
|
frappe.call({
|
||||||
method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields",
|
method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields",
|
||||||
args: {
|
args: {
|
||||||
child_doctype: frm.doc.document_type,
|
child_doctype: document_type,
|
||||||
dimension_name: frm.doc.reference_document,
|
dimension_name: reference_document,
|
||||||
},
|
},
|
||||||
callback: (r) => {
|
callback: (r) => {
|
||||||
if (r.message && r.message.length) {
|
if (
|
||||||
frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message));
|
frm.doc.reference_document !== reference_document ||
|
||||||
} else {
|
frm.doc.document_type !== document_type ||
|
||||||
frm.set_df_property("fetch_from_parent", "hidden", 1);
|
frm.doc.apply_to_all_doctypes ||
|
||||||
|
!frm.doc.istable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return set_parent_field_options(frm, r.message || []);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,3 +135,12 @@ frappe.ui.form.on("Inventory Dimension", {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function set_parent_field_options(frm, fields) {
|
||||||
|
frm.set_df_property("fetch_from_parent", "options", ["", ...fields]);
|
||||||
|
frm.set_df_property("fetch_from_parent", "hidden", !fields.length);
|
||||||
|
|
||||||
|
if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) {
|
||||||
|
return frm.set_value("fetch_from_parent", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ class Item(Document):
|
|||||||
self.validate_conversion_factor()
|
self.validate_conversion_factor()
|
||||||
self.validate_item_type()
|
self.validate_item_type()
|
||||||
self.validate_naming_series()
|
self.validate_naming_series()
|
||||||
|
self.validate_shelf_life()
|
||||||
self.check_for_active_boms()
|
self.check_for_active_boms()
|
||||||
self.fill_customer_code()
|
self.fill_customer_code()
|
||||||
self.check_item_tax()
|
self.check_item_tax()
|
||||||
@@ -344,6 +345,19 @@ class Item(Document):
|
|||||||
).format(self.item_code)
|
).format(self.item_code)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_shelf_life(self):
|
||||||
|
if (
|
||||||
|
self.has_batch_no
|
||||||
|
and self.has_expiry_date
|
||||||
|
and self.create_new_batch
|
||||||
|
and cint(self.shelf_life_in_days) <= 0
|
||||||
|
):
|
||||||
|
frappe.throw(
|
||||||
|
_("{0} must be greater than zero.").format(
|
||||||
|
self.get_label_from_fieldname("shelf_life_in_days")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def clear_retain_sample(self):
|
def clear_retain_sample(self):
|
||||||
if not self.has_batch_no:
|
if not self.has_batch_no:
|
||||||
self.retain_sample = False
|
self.retain_sample = False
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fetch_from": "item_code.item_name",
|
"fetch_from": "item_code.item_name",
|
||||||
|
"fetch_if_empty": 1,
|
||||||
"fieldname": "item_name",
|
"fieldname": "item_name",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
"idx": 1,
|
"idx": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-04-28 15:00:14.079306",
|
"modified": "2026-09-09 13:04:53.623636",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Stock",
|
"module": "Stock",
|
||||||
"name": "Packing Slip Item",
|
"name": "Packing Slip Item",
|
||||||
|
|||||||
@@ -1138,10 +1138,14 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
|
|||||||
returned_qty = flt(item_wise_returned_qty.get(item.name))
|
returned_qty = flt(item_wise_returned_qty.get(item.name))
|
||||||
returned_amount = flt(returned_qty) * flt(item.rate)
|
returned_amount = flt(returned_qty) * flt(item.rate)
|
||||||
pending_amount = flt(item.amount) - returned_amount
|
pending_amount = flt(item.amount) - returned_amount
|
||||||
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
|
|
||||||
pending_amount = flt(item.amount)
|
|
||||||
|
|
||||||
total_billable_amount = abs(flt(item.amount))
|
# When rejected qty is billable, its value is part of the billable base too
|
||||||
|
rejected_amount = 0.0
|
||||||
|
if buying_settings.bill_for_rejected_quantity_in_purchase_invoice:
|
||||||
|
rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount"))
|
||||||
|
pending_amount = flt(item.amount) + rejected_amount
|
||||||
|
|
||||||
|
total_billable_amount = abs(flt(item.amount) + rejected_amount)
|
||||||
if pending_amount > 0:
|
if pending_amount > 0:
|
||||||
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
|
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
|
||||||
|
|
||||||
@@ -1151,9 +1155,7 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
|
|||||||
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
|
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
|
||||||
total_amount = total_billed_amount
|
total_amount = total_billed_amount
|
||||||
|
|
||||||
amount = item.amount
|
amount = flt(item.amount) + rejected_amount
|
||||||
if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
|
|
||||||
amount += flt(item.rejected_qty * item.rate, item.precision("amount"))
|
|
||||||
|
|
||||||
if adjust_incoming_rate:
|
if adjust_incoming_rate:
|
||||||
adjusted_amt = 0.0
|
adjusted_amt = 0.0
|
||||||
|
|||||||
@@ -605,6 +605,42 @@ class TestPurchaseReceipt(FrappeTestCase):
|
|||||||
return_pr.cancel()
|
return_pr.cancel()
|
||||||
pr.cancel()
|
pr.cancel()
|
||||||
|
|
||||||
|
def test_per_billed_for_fully_rejected_receipt(self):
|
||||||
|
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage
|
||||||
|
|
||||||
|
bill_rejected = frappe.db.get_single_value(
|
||||||
|
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||||
|
)
|
||||||
|
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fully rejected receipt: accepted qty 0, whole qty in rejected warehouse
|
||||||
|
pr = make_purchase_receipt(
|
||||||
|
received_qty=10,
|
||||||
|
qty=0,
|
||||||
|
rejected_qty=10,
|
||||||
|
rate=9.5,
|
||||||
|
rejected_warehouse="_Test Warehouse 1 - _TC",
|
||||||
|
do_not_save=True,
|
||||||
|
)
|
||||||
|
pr.items[0].warehouse = ""
|
||||||
|
pr.submit()
|
||||||
|
|
||||||
|
# Bill the rejected qty (10 x 9.5) directly against the receipt item
|
||||||
|
pr.items[0].db_set("billed_amt", 95)
|
||||||
|
update_billing_percentage(pr)
|
||||||
|
|
||||||
|
pr.load_from_db()
|
||||||
|
# Billing the rejected qty must not push per_billed above 100
|
||||||
|
self.assertEqual(pr.per_billed, 100)
|
||||||
|
self.assertEqual(pr.status, "Completed")
|
||||||
|
|
||||||
|
pr.cancel()
|
||||||
|
finally:
|
||||||
|
frappe.db.set_single_value(
|
||||||
|
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", bill_rejected
|
||||||
|
)
|
||||||
|
|
||||||
def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self):
|
def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self):
|
||||||
from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse
|
from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse
|
||||||
|
|
||||||
|
|||||||
@@ -1534,22 +1534,28 @@ class StockEntry(StockController):
|
|||||||
|
|
||||||
self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs"))
|
self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs"))
|
||||||
|
|
||||||
if self.purpose in ("Repack", "Manufacture"):
|
incoming_items, basis, total_basis = self.get_additional_cost_allocation()
|
||||||
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
|
|
||||||
else:
|
|
||||||
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
|
|
||||||
|
|
||||||
if not incoming_items_cost:
|
|
||||||
return
|
|
||||||
|
|
||||||
for d in self.get("items"):
|
for d in self.get("items"):
|
||||||
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
d.additional_cost = 0
|
||||||
d.additional_cost = 0
|
|
||||||
continue
|
if not total_basis:
|
||||||
elif not d.t_warehouse:
|
return
|
||||||
d.additional_cost = 0
|
|
||||||
continue
|
for d in incoming_items:
|
||||||
d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs
|
d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs
|
||||||
|
|
||||||
|
def get_additional_cost_allocation(self):
|
||||||
|
if self.purpose in ("Repack", "Manufacture"):
|
||||||
|
incoming_items = [d for d in self.get("items") if d.is_finished_item]
|
||||||
|
else:
|
||||||
|
incoming_items = [d for d in self.get("items") if d.t_warehouse]
|
||||||
|
|
||||||
|
total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items)
|
||||||
|
if total_basic_amount:
|
||||||
|
return incoming_items, "basic_amount", total_basic_amount
|
||||||
|
|
||||||
|
return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items)
|
||||||
|
|
||||||
def update_valuation_rate(self):
|
def update_valuation_rate(self):
|
||||||
for d in self.get("items"):
|
for d in self.get("items"):
|
||||||
@@ -2066,32 +2072,20 @@ class StockEntry(StockController):
|
|||||||
def get_gl_entries(self, warehouse_account):
|
def get_gl_entries(self, warehouse_account):
|
||||||
gl_entries = super().get_gl_entries(warehouse_account)
|
gl_entries = super().get_gl_entries(warehouse_account)
|
||||||
|
|
||||||
if self.purpose in ("Repack", "Manufacture"):
|
incoming_items, basis, divide_based_on = self.get_additional_cost_allocation()
|
||||||
total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
|
|
||||||
else:
|
|
||||||
total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
|
|
||||||
|
|
||||||
divide_based_on = total_basic_amount
|
|
||||||
|
|
||||||
if self.get("additional_costs") and not total_basic_amount:
|
|
||||||
# if total_basic_amount is 0, distribute additional charges based on qty
|
|
||||||
divide_based_on = sum(item.qty for item in list(self.get("items")))
|
|
||||||
|
|
||||||
item_account_wise_additional_cost = {}
|
item_account_wise_additional_cost = {}
|
||||||
|
|
||||||
for t in self.get("additional_costs"):
|
for t in self.get("additional_costs"):
|
||||||
for d in self.get("items"):
|
if not divide_based_on:
|
||||||
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
continue
|
||||||
continue
|
for d in incoming_items:
|
||||||
elif not d.t_warehouse:
|
|
||||||
continue
|
|
||||||
|
|
||||||
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
|
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
|
||||||
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
|
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
|
||||||
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
|
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
|
||||||
)
|
)
|
||||||
|
|
||||||
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
|
multiply_based_on = flt(d.get(basis))
|
||||||
|
|
||||||
item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += (
|
item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += (
|
||||||
flt(t.amount * multiply_based_on) / divide_based_on
|
flt(t.amount * multiply_based_on) / divide_based_on
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
from frappe.permissions import add_user_permission, remove_user_permission
|
from frappe.permissions import add_user_permission, remove_user_permission
|
||||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||||
from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today
|
from frappe.utils import add_days, cstr, flt, get_time, getdate, nowdate, nowtime, today
|
||||||
|
|
||||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||||
@@ -1614,10 +1614,12 @@ class TestStockEntry(FrappeTestCase):
|
|||||||
se.insert()
|
se.insert()
|
||||||
se.submit()
|
se.submit()
|
||||||
|
|
||||||
|
self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items])
|
||||||
|
|
||||||
self.check_gl_entries(
|
self.check_gl_entries(
|
||||||
"Stock Entry",
|
"Stock Entry",
|
||||||
se.name,
|
se.name,
|
||||||
sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
|
sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_conversion_factor_change(self):
|
def test_conversion_factor_change(self):
|
||||||
@@ -1663,6 +1665,184 @@ class TestStockEntry(FrappeTestCase):
|
|||||||
distributed_costs = [d.additional_cost for d in se.items]
|
distributed_costs = [d.additional_cost for d in se.items]
|
||||||
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
||||||
|
|
||||||
|
def test_additional_cost_distribution_manufacture_zero_valued_items(self):
|
||||||
|
se = frappe.get_doc(
|
||||||
|
doctype="Stock Entry",
|
||||||
|
purpose="Manufacture",
|
||||||
|
additional_costs=[frappe._dict(base_amount=100)],
|
||||||
|
items=[
|
||||||
|
frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10),
|
||||||
|
frappe._dict(
|
||||||
|
item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1
|
||||||
|
),
|
||||||
|
frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
se.distribute_additional_costs()
|
||||||
|
|
||||||
|
distributed_costs = [d.additional_cost for d in se.items]
|
||||||
|
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
||||||
|
|
||||||
|
def test_additional_cost_distribution_zero_valued_items(self):
|
||||||
|
se = frappe.get_doc(
|
||||||
|
doctype="Stock Entry",
|
||||||
|
purpose="Material Receipt",
|
||||||
|
additional_costs=[frappe._dict(base_amount=100)],
|
||||||
|
items=[
|
||||||
|
frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"),
|
||||||
|
frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
se.distribute_additional_costs()
|
||||||
|
|
||||||
|
distributed_costs = [d.additional_cost for d in se.items]
|
||||||
|
self.assertEqual([40.0, 60.0], distributed_costs)
|
||||||
|
|
||||||
|
def test_additional_cost_gl_for_zero_valued_manufacture(self):
|
||||||
|
company = "_Test Company with perpetual inventory"
|
||||||
|
rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name
|
||||||
|
fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name
|
||||||
|
|
||||||
|
receipt = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Stock Entry",
|
||||||
|
"purpose": "Material Receipt",
|
||||||
|
"stock_entry_type": "Material Receipt",
|
||||||
|
"posting_date": nowdate(),
|
||||||
|
"company": company,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_code": rm,
|
||||||
|
"qty": 5,
|
||||||
|
"basic_rate": 0,
|
||||||
|
"uom": "Nos",
|
||||||
|
"t_warehouse": "Stores - TCP1",
|
||||||
|
"allow_zero_valuation_rate": 1,
|
||||||
|
"cost_center": "Main - TCP1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
receipt.insert()
|
||||||
|
receipt.submit()
|
||||||
|
|
||||||
|
se = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Stock Entry",
|
||||||
|
"purpose": "Manufacture",
|
||||||
|
"stock_entry_type": "Manufacture",
|
||||||
|
"posting_date": nowdate(),
|
||||||
|
"company": company,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_code": rm,
|
||||||
|
"qty": 5,
|
||||||
|
"uom": "Nos",
|
||||||
|
"s_warehouse": "Stores - TCP1",
|
||||||
|
"cost_center": "Main - TCP1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_code": fg,
|
||||||
|
"qty": 5,
|
||||||
|
"uom": "Nos",
|
||||||
|
"t_warehouse": "Finished Goods - TCP1",
|
||||||
|
"is_finished_item": 1,
|
||||||
|
"cost_center": "Main - TCP1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"additional_costs": [
|
||||||
|
{
|
||||||
|
"expense_account": "Miscellaneous Expenses - TCP1",
|
||||||
|
"amount": 500,
|
||||||
|
"description": "freight",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
se.insert()
|
||||||
|
se.submit()
|
||||||
|
|
||||||
|
self.assertEqual(500.0, se.items[1].additional_cost)
|
||||||
|
self.check_gl_entries(
|
||||||
|
"Stock Entry",
|
||||||
|
se.name,
|
||||||
|
sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_additional_cost_gl_matches_valuation_split(self):
|
||||||
|
company = "_Test Company with perpetual inventory"
|
||||||
|
cost_center = "_Test Additional Cost CC - TCP1"
|
||||||
|
if not frappe.db.exists("Cost Center", cost_center):
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Cost Center",
|
||||||
|
"cost_center_name": "_Test Additional Cost CC",
|
||||||
|
"company": company,
|
||||||
|
"is_group": 0,
|
||||||
|
"parent_cost_center": "_Test Company with perpetual inventory - TCP1",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}]
|
||||||
|
item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name
|
||||||
|
uoms[1]["conversion_factor"] = 3
|
||||||
|
item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name
|
||||||
|
|
||||||
|
se = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Stock Entry",
|
||||||
|
"purpose": "Material Receipt",
|
||||||
|
"stock_entry_type": "Material Receipt",
|
||||||
|
"posting_date": nowdate(),
|
||||||
|
"company": company,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_code": item_a,
|
||||||
|
"qty": 1,
|
||||||
|
"basic_rate": 0,
|
||||||
|
"uom": "Box",
|
||||||
|
"conversion_factor": 2,
|
||||||
|
"t_warehouse": "Stores - TCP1",
|
||||||
|
"allow_zero_valuation_rate": 1,
|
||||||
|
"cost_center": "Main - TCP1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_code": item_b,
|
||||||
|
"qty": 1,
|
||||||
|
"basic_rate": 0,
|
||||||
|
"uom": "Box",
|
||||||
|
"conversion_factor": 3,
|
||||||
|
"t_warehouse": "Stores - TCP1",
|
||||||
|
"allow_zero_valuation_rate": 1,
|
||||||
|
"cost_center": cost_center,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"additional_costs": [
|
||||||
|
{
|
||||||
|
"expense_account": "Miscellaneous Expenses - TCP1",
|
||||||
|
"amount": 100,
|
||||||
|
"description": "misc",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
se.insert()
|
||||||
|
se.submit()
|
||||||
|
|
||||||
|
self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items])
|
||||||
|
|
||||||
|
expense_by_cost_center = frappe.get_all(
|
||||||
|
"GL Entry",
|
||||||
|
filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"},
|
||||||
|
fields=["cost_center", "credit"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"Main - TCP1": 40.0, cost_center: 60.0},
|
||||||
|
{d.cost_center: d.credit for d in expense_by_cost_center},
|
||||||
|
)
|
||||||
|
|
||||||
def test_additional_cost_distribution_non_manufacture(self):
|
def test_additional_cost_distribution_non_manufacture(self):
|
||||||
se = frappe.get_doc(
|
se = frappe.get_doc(
|
||||||
doctype="Stock Entry",
|
doctype="Stock Entry",
|
||||||
|
|||||||
@@ -525,7 +525,10 @@ class StockReconciliation(StockController):
|
|||||||
|
|
||||||
rate_precision = item.precision("valuation_rate")
|
rate_precision = item.precision("valuation_rate")
|
||||||
rate = flt(item_dict.get("rate"), rate_precision)
|
rate = flt(item_dict.get("rate"), rate_precision)
|
||||||
valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None
|
# an unset rate means "keep the current one", an explicit zero is a real revaluation
|
||||||
|
valuation_rate = (
|
||||||
|
flt(item.valuation_rate, rate_precision) if item.valuation_rate not in ("", None) else None
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
(item.qty is None or item.qty == item_dict.get("qty"))
|
(item.qty is None or item.qty == item_dict.get("qty"))
|
||||||
and (valuation_rate is None or valuation_rate == rate)
|
and (valuation_rate is None or valuation_rate == rate)
|
||||||
@@ -568,7 +571,10 @@ class StockReconciliation(StockController):
|
|||||||
amount_precision = item.precision("amount")
|
amount_precision = item.precision("amount")
|
||||||
|
|
||||||
new_qty = flt(item.qty, qty_precision)
|
new_qty = flt(item.qty, qty_precision)
|
||||||
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"))
|
# an explicitly set zero rate is a real revaluation, don't fall back to the current rate
|
||||||
|
new_valuation_rate = flt(
|
||||||
|
item.valuation_rate if item.valuation_rate not in ("", None) else item_dict.get("rate")
|
||||||
|
)
|
||||||
|
|
||||||
current_qty = flt(item_dict.get("qty"), qty_precision)
|
current_qty = flt(item_dict.get("qty"), qty_precision)
|
||||||
current_valuation_rate = flt(item_dict.get("rate"))
|
current_valuation_rate = flt(item_dict.get("rate"))
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
|
|||||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||||
from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after
|
from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after
|
||||||
from erpnext.stock.tests.test_utils import StockTestMixin
|
from erpnext.stock.tests.test_utils import StockTestMixin
|
||||||
from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method
|
from erpnext.stock.utils import (
|
||||||
|
get_incoming_rate,
|
||||||
|
get_stock_balance,
|
||||||
|
get_stock_value_on,
|
||||||
|
get_valuation_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
||||||
@@ -1583,6 +1588,85 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
|||||||
self.assertEqual(sr.difference_amount, 100 * -1)
|
self.assertEqual(sr.difference_amount, 100 * -1)
|
||||||
self.assertTrue(sr.items[0].qty == 0)
|
self.assertTrue(sr.items[0].qty == 0)
|
||||||
|
|
||||||
|
def test_difference_amount_for_zero_valuation_rate(self):
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
|
||||||
|
item_code = self.make_item("Test Item Stock Reco Zero Valuation Rate").name
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100)
|
||||||
|
|
||||||
|
sr = create_stock_reconciliation(
|
||||||
|
item_code=item_code, warehouse=warehouse, qty=5, rate=0, do_not_save=1
|
||||||
|
)
|
||||||
|
sr.items[0].allow_zero_valuation_rate = 1
|
||||||
|
sr.save()
|
||||||
|
|
||||||
|
# qty is unchanged, the stock is revalued from 5 x 100 to 5 x 0
|
||||||
|
self.assertEqual(sr.items[0].current_valuation_rate, 100)
|
||||||
|
self.assertEqual(sr.items[0].valuation_rate, 0)
|
||||||
|
self.assertEqual(sr.difference_amount, -500)
|
||||||
|
|
||||||
|
sr.submit()
|
||||||
|
sr.reload()
|
||||||
|
|
||||||
|
self.assertEqual(sr.difference_amount, -500)
|
||||||
|
self.assertEqual(
|
||||||
|
frappe.db.get_value(
|
||||||
|
"Stock Ledger Entry",
|
||||||
|
{"voucher_no": sr.name, "is_cancelled": 0},
|
||||||
|
"stock_value_difference",
|
||||||
|
),
|
||||||
|
-500,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_change_row_removed_when_valuation_rate_is_blank(self):
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
|
||||||
|
item_code = self.make_item("Test Item Stock Reco Blank Valuation Rate").name
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100)
|
||||||
|
|
||||||
|
sr = create_stock_reconciliation(
|
||||||
|
item_code=item_code, warehouse=warehouse, qty=5, rate=None, do_not_save=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# a blank rate means "keep the current rate", so nothing changed on this row
|
||||||
|
self.assertRaises(EmptyStockReconciliationItemsError, sr.save)
|
||||||
|
|
||||||
|
def test_set_existing_stock_valuation_to_zero(self):
|
||||||
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
|
||||||
|
item_code = self.make_item("Test Item Stock Reco Set Valuation Zero").name
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
|
||||||
|
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=50)
|
||||||
|
|
||||||
|
sr = create_stock_reconciliation(
|
||||||
|
item_code=item_code, warehouse=warehouse, qty=10, rate=0, do_not_save=1
|
||||||
|
)
|
||||||
|
sr.items[0].allow_zero_valuation_rate = 1
|
||||||
|
|
||||||
|
# only the rate changes, the row must not be dropped as "no change"
|
||||||
|
sr.save()
|
||||||
|
self.assertEqual(len(sr.items), 1)
|
||||||
|
|
||||||
|
sr.submit()
|
||||||
|
|
||||||
|
sle = frappe.db.get_value(
|
||||||
|
"Stock Ledger Entry",
|
||||||
|
{"voucher_no": sr.name, "is_cancelled": 0},
|
||||||
|
["qty_after_transaction", "valuation_rate", "stock_value"],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(sle.qty_after_transaction, 10)
|
||||||
|
self.assertEqual(sle.valuation_rate, 0)
|
||||||
|
self.assertEqual(sle.stock_value, 0)
|
||||||
|
|
||||||
|
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (10, 0.0))
|
||||||
|
|
||||||
def test_stock_reco_recalculate_qty_for_backdated_entry(self):
|
def test_stock_reco_recalculate_qty_for_backdated_entry(self):
|
||||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class SubcontractingOrder(SubcontractingController):
|
|||||||
self.validate_service_items()
|
self.validate_service_items()
|
||||||
self.validate_supplied_items()
|
self.validate_supplied_items()
|
||||||
self.set_missing_values()
|
self.set_missing_values()
|
||||||
|
self.validate_with_previous_doc()
|
||||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
@@ -108,6 +109,18 @@ class SubcontractingOrder(SubcontractingController):
|
|||||||
self.update_status()
|
self.update_status()
|
||||||
self.update_subcontracted_quantity_in_po(cancel=True)
|
self.update_subcontracted_quantity_in_po(cancel=True)
|
||||||
|
|
||||||
|
def validate_with_previous_doc(self):
|
||||||
|
super().validate_with_previous_doc(
|
||||||
|
{
|
||||||
|
"Purchase Order Item": {
|
||||||
|
"ref_dn_field": "purchase_order_item",
|
||||||
|
"compare_fields": [["project", "="]],
|
||||||
|
"is_child_table": True,
|
||||||
|
"allow_duplicate_prev_row_id": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def validate_purchase_order_for_subcontracting(self):
|
def validate_purchase_order_for_subcontracting(self):
|
||||||
if self.purchase_order:
|
if self.purchase_order:
|
||||||
po = frappe.get_doc("Purchase Order", self.purchase_order)
|
po = frappe.get_doc("Purchase Order", self.purchase_order)
|
||||||
@@ -211,10 +224,10 @@ class SubcontractingOrder(SubcontractingController):
|
|||||||
if si.fg_item:
|
if si.fg_item:
|
||||||
item = frappe.get_doc("Item", si.fg_item)
|
item = frappe.get_doc("Item", si.fg_item)
|
||||||
|
|
||||||
qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value(
|
qty, subcontracted_quantity, fg_item_qty, project = frappe.db.get_value(
|
||||||
"Purchase Order Item",
|
"Purchase Order Item",
|
||||||
si.purchase_order_item,
|
si.purchase_order_item,
|
||||||
["qty", "subcontracted_quantity", "fg_item_qty"],
|
["qty", "subcontracted_quantity", "fg_item_qty", "project"],
|
||||||
)
|
)
|
||||||
available_qty = flt(qty) - flt(subcontracted_quantity)
|
available_qty = flt(qty) - flt(subcontracted_quantity)
|
||||||
|
|
||||||
@@ -250,6 +263,7 @@ class SubcontractingOrder(SubcontractingController):
|
|||||||
"purchase_order_item": si.purchase_order_item,
|
"purchase_order_item": si.purchase_order_item,
|
||||||
"material_request": si.material_request,
|
"material_request": si.material_request,
|
||||||
"material_request_item": si.material_request_item,
|
"material_request_item": si.material_request_item,
|
||||||
|
"project": project,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import (
|
|||||||
set_backflush_based_on,
|
set_backflush_based_on,
|
||||||
)
|
)
|
||||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||||
|
from erpnext.projects.doctype.project.test_project import make_project
|
||||||
from erpnext.stock.doctype.item.test_item import make_item
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
||||||
@@ -112,6 +113,24 @@ class TestSubcontractingOrder(FrappeTestCase):
|
|||||||
sco.load_from_db()
|
sco.load_from_db()
|
||||||
self.assertEqual(sco.status, "Partially Received")
|
self.assertEqual(sco.status, "Partially Received")
|
||||||
|
|
||||||
|
def test_project_is_carried_over_from_purchase_order(self):
|
||||||
|
project = make_project({"project_name": "_Test SCO Project"}).name
|
||||||
|
po = make_subcontracted_purchase_order(project)
|
||||||
|
|
||||||
|
sco = get_mapped_subcontracting_order(source_name=po.name)
|
||||||
|
|
||||||
|
self.assertEqual(sco.project, project)
|
||||||
|
self.assertEqual(sco.items[0].project, project)
|
||||||
|
|
||||||
|
def test_project_cannot_differ_from_purchase_order(self):
|
||||||
|
project = make_project({"project_name": "_Test SCO Project"}).name
|
||||||
|
other_project = make_project({"project_name": "_Test SCO Project 2"}).name
|
||||||
|
po = make_subcontracted_purchase_order(project)
|
||||||
|
|
||||||
|
sco = get_mapped_subcontracting_order(source_name=po.name)
|
||||||
|
sco.items[0].project = other_project
|
||||||
|
self.assertRaises(frappe.ValidationError, sco.save)
|
||||||
|
|
||||||
def test_make_rm_stock_entry(self):
|
def test_make_rm_stock_entry(self):
|
||||||
sco = get_subcontracting_order()
|
sco = get_subcontracting_order()
|
||||||
rm_items = get_rm_items(sco.supplied_items)
|
rm_items = get_rm_items(sco.supplied_items)
|
||||||
@@ -873,3 +892,31 @@ def create_subcontracting_order(**args):
|
|||||||
sco.submit()
|
sco.submit()
|
||||||
|
|
||||||
return sco
|
return sco
|
||||||
|
|
||||||
|
|
||||||
|
def make_subcontracted_purchase_order(project):
|
||||||
|
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||||
|
|
||||||
|
service_items = [
|
||||||
|
{
|
||||||
|
"warehouse": "_Test Warehouse - _TC",
|
||||||
|
"item_code": "Subcontracted Service Item 7",
|
||||||
|
"qty": 10,
|
||||||
|
"rate": 100,
|
||||||
|
"fg_item": "Subcontracted Item SA7",
|
||||||
|
"fg_item_qty": 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
po = create_purchase_order(
|
||||||
|
rm_items=service_items,
|
||||||
|
is_subcontracted=1,
|
||||||
|
supplier_warehouse="_Test Warehouse 1 - _TC",
|
||||||
|
do_not_submit=1,
|
||||||
|
)
|
||||||
|
po.project = project
|
||||||
|
for item in po.items:
|
||||||
|
item.project = project
|
||||||
|
po.save()
|
||||||
|
po.submit()
|
||||||
|
|
||||||
|
return po
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ class SubcontractingReceipt(SubcontractingController):
|
|||||||
self.get_scrap_items()
|
self.get_scrap_items()
|
||||||
|
|
||||||
self.set_missing_values()
|
self.set_missing_values()
|
||||||
|
self.validate_with_previous_doc()
|
||||||
|
|
||||||
if self.get("_action") == "submit":
|
if self.get("_action") == "submit":
|
||||||
self.validate_scrap_items()
|
self.validate_scrap_items()
|
||||||
@@ -159,6 +160,24 @@ class SubcontractingReceipt(SubcontractingController):
|
|||||||
self.set_supplied_items_expense_account()
|
self.set_supplied_items_expense_account()
|
||||||
self.set_supplied_items_cost_center()
|
self.set_supplied_items_cost_center()
|
||||||
|
|
||||||
|
def validate_with_previous_doc(self):
|
||||||
|
super().validate_with_previous_doc(
|
||||||
|
{
|
||||||
|
"Subcontracting Order Item": {
|
||||||
|
"ref_dn_field": "subcontracting_order_item",
|
||||||
|
"compare_fields": [["project", "="]],
|
||||||
|
"is_child_table": True,
|
||||||
|
"allow_duplicate_prev_row_id": True,
|
||||||
|
},
|
||||||
|
"Purchase Order Item": {
|
||||||
|
"ref_dn_field": "purchase_order_item",
|
||||||
|
"compare_fields": [["project", "="]],
|
||||||
|
"is_child_table": True,
|
||||||
|
"allow_duplicate_prev_row_id": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
self.validate_closed_subcontracting_order()
|
self.validate_closed_subcontracting_order()
|
||||||
self.validate_bom_required_qty()
|
self.validate_bom_required_qty()
|
||||||
@@ -962,5 +981,7 @@ def add_po_items_to_pr(scr_doc, target_doc):
|
|||||||
"warehouse": item.warehouse,
|
"warehouse": item.warehouse,
|
||||||
"purchase_order": item.parent,
|
"purchase_order": item.parent,
|
||||||
"purchase_order_item": item.name,
|
"purchase_order_item": item.name,
|
||||||
|
"project": item.project,
|
||||||
|
"cost_center": item.cost_center,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import (
|
|||||||
set_backflush_based_on,
|
set_backflush_based_on,
|
||||||
)
|
)
|
||||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||||
|
from erpnext.projects.doctype.project.test_project import make_project
|
||||||
from erpnext.stock.doctype.item.test_item import make_item
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||||
@@ -38,6 +39,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
|
|||||||
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
||||||
make_subcontracting_receipt,
|
make_subcontracting_receipt,
|
||||||
)
|
)
|
||||||
|
from erpnext.subcontracting.doctype.subcontracting_order.test_subcontracting_order import (
|
||||||
|
make_subcontracted_purchase_order,
|
||||||
|
)
|
||||||
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
|
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
|
||||||
BOMQuantityError,
|
BOMQuantityError,
|
||||||
)
|
)
|
||||||
@@ -50,6 +54,26 @@ class TestSubcontractingReceipt(FrappeTestCase):
|
|||||||
make_service_items()
|
make_service_items()
|
||||||
make_bom_for_subcontracted_items()
|
make_bom_for_subcontracted_items()
|
||||||
|
|
||||||
|
def test_project_is_carried_over_from_subcontracting_order(self):
|
||||||
|
project = make_project({"project_name": "_Test SCR Project"}).name
|
||||||
|
po = make_subcontracted_purchase_order(project)
|
||||||
|
sco = get_subcontracting_order(po_name=po.name)
|
||||||
|
|
||||||
|
scr = make_subcontracting_receipt(sco.name)
|
||||||
|
|
||||||
|
self.assertEqual(scr.project, project)
|
||||||
|
self.assertEqual(scr.items[0].project, project)
|
||||||
|
|
||||||
|
def test_project_cannot_differ_from_subcontracting_order(self):
|
||||||
|
project = make_project({"project_name": "_Test SCR Project"}).name
|
||||||
|
other_project = make_project({"project_name": "_Test SCR Project 2"}).name
|
||||||
|
po = make_subcontracted_purchase_order(project)
|
||||||
|
sco = get_subcontracting_order(po_name=po.name)
|
||||||
|
|
||||||
|
scr = make_subcontracting_receipt(sco.name)
|
||||||
|
scr.items[0].project = other_project
|
||||||
|
self.assertRaises(frappe.ValidationError, scr.save)
|
||||||
|
|
||||||
def test_subcontracting(self):
|
def test_subcontracting(self):
|
||||||
set_backflush_based_on("BOM")
|
set_backflush_based_on("BOM")
|
||||||
make_stock_entry(item_code="_Test Item", qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
|
make_stock_entry(item_code="_Test Item", qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
|
||||||
|
|||||||
Reference in New Issue
Block a user